@sjawhar/pi-legion-envoy 5.18.1 → 5.20.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/envoy.js CHANGED
@@ -29904,6 +29904,16 @@ function capAgentStreamText(text, limit) {
29904
29904
  return text;
29905
29905
  return text.slice(0, limit) + AGENT_STREAM_TRUNCATION_SUFFIX;
29906
29906
  }
29907
+ // ../contracts/src/claim-holds.ts
29908
+ function claimHolds(claim, titles) {
29909
+ if (claim.actor.kind !== "session") {
29910
+ return "holds";
29911
+ }
29912
+ if (titles === undefined) {
29913
+ return "unknown";
29914
+ }
29915
+ return titles.has(claim.actor.id) ? "holds" : "lapsed";
29916
+ }
29907
29917
  // ../contracts/src/dispatch-href.ts
29908
29918
  function itemFromSearch(search) {
29909
29919
  const params = new URLSearchParams(search);
@@ -33414,17 +33424,19 @@ function componentsLine(components) {
33414
33424
  }
33415
33425
  }
33416
33426
  function claimText(claim, titles) {
33417
- return `${actorLabel(claim.actor, titles)} since ${claim.at}`;
33427
+ const holding = claimHolds(claim, titles);
33428
+ const marker = holding === "unknown" ? " \xB7 liveness unknown" : holding === "lapsed" ? " \xB7 not running" : "";
33429
+ return `${actorLabel(claim.actor, titles)} since ${claim.at}${marker}`;
33418
33430
  }
33419
33431
  async function liveSessionTitles(client, needed) {
33420
33432
  if (!needed) {
33421
- return new Map;
33433
+ return;
33422
33434
  }
33423
33435
  try {
33424
33436
  const agents = await client.listAgents();
33425
33437
  return new Map(agents.map((agent) => [agent.session_id, agent.title]));
33426
33438
  } catch {
33427
- return new Map;
33439
+ return;
33428
33440
  }
33429
33441
  }
33430
33442
  function holdsSession(claim) {
@@ -33456,6 +33468,9 @@ function issueSummary(issue2, events, references, graph, titles) {
33456
33468
  throw new Error("Dispatch issue is missing components");
33457
33469
  if (issue2.claim === undefined)
33458
33470
  throw new Error("Dispatch issue is missing claim");
33471
+ if (issue2.external_links === undefined) {
33472
+ throw new Error("Dispatch issue is missing external_links");
33473
+ }
33459
33474
  return [
33460
33475
  `Title: ${issue2.title}`,
33461
33476
  `Key: ${issue2.key}`,
@@ -33467,6 +33482,8 @@ function issueSummary(issue2, events, references, graph, titles) {
33467
33482
  componentsLine(issue2.components),
33468
33483
  `Route: ${routeText(issue2, titles)}`,
33469
33484
  ...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
33485
+ "External links:",
33486
+ ...issue2.external_links.length === 0 ? ["- none"] : issue2.external_links.map((link) => `- ${link.url}${link.kind === undefined ? "" : ` (${link.kind})`}`),
33470
33487
  "Open asks:",
33471
33488
  ...asks.length === 0 ? ["- none"] : asks.map((ask) => `- ${ask.id}: ${ask.question}`),
33472
33489
  "References:",
package/dist/legion.js CHANGED
@@ -30073,6 +30073,16 @@ function capAgentStreamText(text, limit) {
30073
30073
  return text;
30074
30074
  return text.slice(0, limit) + AGENT_STREAM_TRUNCATION_SUFFIX;
30075
30075
  }
30076
+ // ../contracts/src/claim-holds.ts
30077
+ function claimHolds(claim, titles) {
30078
+ if (claim.actor.kind !== "session") {
30079
+ return "holds";
30080
+ }
30081
+ if (titles === undefined) {
30082
+ return "unknown";
30083
+ }
30084
+ return titles.has(claim.actor.id) ? "holds" : "lapsed";
30085
+ }
30076
30086
  // ../contracts/src/dispatch-href.ts
30077
30087
  function itemFromSearch(search) {
30078
30088
  const params = new URLSearchParams(search);
@@ -32353,17 +32363,19 @@ function componentsLine(components) {
32353
32363
  }
32354
32364
  }
32355
32365
  function claimText(claim, titles) {
32356
- return `${actorLabel(claim.actor, titles)} since ${claim.at}`;
32366
+ const holding = claimHolds(claim, titles);
32367
+ const marker = holding === "unknown" ? " \xB7 liveness unknown" : holding === "lapsed" ? " \xB7 not running" : "";
32368
+ return `${actorLabel(claim.actor, titles)} since ${claim.at}${marker}`;
32357
32369
  }
32358
32370
  async function liveSessionTitles(client, needed) {
32359
32371
  if (!needed) {
32360
- return new Map;
32372
+ return;
32361
32373
  }
32362
32374
  try {
32363
32375
  const agents = await client.listAgents();
32364
32376
  return new Map(agents.map((agent) => [agent.session_id, agent.title]));
32365
32377
  } catch {
32366
- return new Map;
32378
+ return;
32367
32379
  }
32368
32380
  }
32369
32381
  function holdsSession(claim) {
@@ -32395,6 +32407,9 @@ function issueSummary(issue2, events, references, graph, titles) {
32395
32407
  throw new Error("Dispatch issue is missing components");
32396
32408
  if (issue2.claim === undefined)
32397
32409
  throw new Error("Dispatch issue is missing claim");
32410
+ if (issue2.external_links === undefined) {
32411
+ throw new Error("Dispatch issue is missing external_links");
32412
+ }
32398
32413
  return [
32399
32414
  `Title: ${issue2.title}`,
32400
32415
  `Key: ${issue2.key}`,
@@ -32406,6 +32421,8 @@ function issueSummary(issue2, events, references, graph, titles) {
32406
32421
  componentsLine(issue2.components),
32407
32422
  `Route: ${routeText(issue2, titles)}`,
32408
32423
  ...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
32424
+ "External links:",
32425
+ ...issue2.external_links.length === 0 ? ["- none"] : issue2.external_links.map((link) => `- ${link.url}${link.kind === undefined ? "" : ` (${link.kind})`}`),
32409
32426
  "Open asks:",
32410
32427
  ...asks.length === 0 ? ["- none"] : asks.map((ask) => `- ${ask.id}: ${ask.question}`),
32411
32428
  "References:",
@@ -33478,7 +33495,7 @@ import { logger } from "@oh-my-pi/pi-utils";
33478
33495
  // package.json
33479
33496
  var package_default = {
33480
33497
  name: "@sjawhar/pi-legion-envoy",
33481
- version: "5.18.1",
33498
+ version: "5.20.0",
33482
33499
  type: "module",
33483
33500
  omp: {
33484
33501
  extensions: [
@@ -10,7 +10,7 @@ event intake, process lifecycle, credentials, and role delivery.
10
10
  | `dispatch/` | every role, and any session writing to Dispatch | specs, asks, comments, artifacts, and messages on native Dispatch |
11
11
  | `envoy/` | every role | subscriptions, agent-to-agent messages, and topic formats |
12
12
  | `legion-architect/` | root and sub-architects | tree ownership, decomposition, waves, gates, integration, sign-off |
13
- | `legion-controller/` | the controller root process | wake routing, backlog admission, escalation |
13
+ | `legion-controller/` | the controller root process | wake routing, keeping the admission slots full from `todo`, the daily report, escalation |
14
14
  | `legion-oracle/` | any role doing research | repository-grounded research |
15
15
  | `legion-retro/` | the implementer, at retro | the pre-merge retrospective and its Dispatch message |
16
16
  | `legion-worker/` | planner, implementer, tester, reviewer, merger | the phase contracts: handoffs, GitHub identity, PR body and READY discipline, the merge-gate order |
@@ -215,8 +215,11 @@ dispatch_claim({ issue: "LEGION-234", release: true }) // I have stopped; it i
215
215
 
216
216
  A claim records **your** session — the one making the call, never another — and shows on every
217
217
  read of the issue: the dashboard header, the issue list and board, `dispatch_read` (a
218
- `Claimed by:` line) and `dispatch_issues` (a claim on the row). `dispatch_issues` plus the
219
- dashboard's **Unclaimed** filter is how you find work nobody is on.
218
+ `Claimed by:` line) and `dispatch_issues` (a claim on the row). A session's claim there ends
219
+ `· not running` when the live agent registry does not list that session, as the dashboard's chip
220
+ does — that claim is free to take — and `· liveness unknown` when the registry could not be read,
221
+ which says nothing either way. `dispatch_issues` plus the dashboard's **Unclaimed** filter is how
222
+ you find work nobody is on.
220
223
 
221
224
  - **`409 ISSUE_CLAIMED` means someone else holds this issue.** When it is another session, the
222
225
  refusal names it and says it is still running: do not work the issue in parallel — message
@@ -274,7 +277,8 @@ The two clears differ: `priority` clears with `null`, while `parent` and `route`
274
277
  Guessing the other one is a refusal either way.
275
278
 
276
279
  Link the pull request that delivers the issue in `external_links` when you open it; the issue page
277
- renders its state and checks from that link. The call is authenticated with the same bearer as every
280
+ renders its state and checks from that link, and `dispatch_read` lists it under `External links:`.
281
+ The call is authenticated with the same bearer as every
278
282
  other `dispatch_*` tool: a Legion pane reads it from the `DISPATCH_TOKEN_FILE` path the daemon sets on
279
283
  the pane; an OMP session outside Legion reads `dispatch.token` from `~/.config/opencode/envoy.json`.
280
284
 
@@ -256,10 +256,10 @@ Preserve this order exactly:
256
256
 
257
257
  What returns the tree to review: a changed diff — a commit above the approved head that
258
258
  touches anything outside `docs/solutions/`, or a conflict-resolution merge whose fingerprint
259
- (`skill://legion-worker`'s unchanged-diff check) differs from the approved head's. What does not: retro's
259
+ (the unchanged-diff check, `skill://legion-worker/references/conflicts-and-rewrites.md`) differs from the approved head's. What does not: retro's
260
260
  `docs/solutions/` commit, and a merge forced by a GitHub-reported conflict whose fingerprint
261
261
  is unchanged. For that merge the order is: the implementer merges the bookmark forward with the
262
- destination (`legion-worker`'s forward-merge procedure — `jj new legion/<KEY> <destination>`,
262
+ destination (the forward-merge procedure in `skill://legion-worker/references/conflicts-and-rewrites.md` — `jj new legion/<KEY> <destination>`,
263
263
  never a rebase, since a rebase rewrites every descendant of the chain's fork point, including
264
264
  another tree's branch stacked on it), pushes it with the ordinary push procedure (a genuine
265
265
  fast-forward), and posts the before/after fingerprints; the tester re-runs the bare gates only;
@@ -1,13 +1,14 @@
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, keeping the admission slots full from `todo` issues, the daily report, architect escalation, resync healing, or human interaction.
4
4
  ---
5
5
 
6
6
  # Legion Controller
7
7
 
8
- The controller is the one persistent, wake-driven session for a Legion project. It makes
9
- triage, escalation, and human-interaction judgments; it never does phase-worker work or
10
- routes raw events into an architect.
8
+ The controller is the one persistent, wake-driven session for a Legion project. It keeps the
9
+ project's admission slots full with the highest-priority work, and makes triage, escalation, and
10
+ human-interaction judgments; it never does phase-worker work or routes raw events into an
11
+ architect.
11
12
 
12
13
  ## Start and claim the controller role
13
14
 
@@ -87,8 +88,8 @@ mints a new secret, so your grants stop working and the role moves to the new se
87
88
 
88
89
  The Go daemon's controller topic is a wake for a session that is running when it is published.
89
90
  Envoy hands an Oh My Pi session no retained copy of a notice published before it subscribed, so a
90
- hold, a tree architect's failed claim, or a new triage root from while no controller ran never
91
- arrives as a wake. At every start, before anything else:
91
+ hold, a tree architect's failed claim, a new triage root, or a freed slot from while no controller
92
+ ran never arrives as a wake. At every start, before anything else:
92
93
 
93
94
  1. Read `legion state --json` and handle each issue whose `issues.<KEY>.phase` is `held` (its
94
95
  `issues.<KEY>.holdReason` is `escalated` when its architect sent it to you, and absent while the
@@ -98,15 +99,19 @@ arrives as a wake. At every start, before anything else:
98
99
  as the matching wake below. A parked tree (root phase `done`: it lingers or is closed) needs
99
100
  nothing from you: a failed architect ignores the park and reads `failed` until the tree closes.
100
101
  2. List the project's triage issues handed to Legion with
101
- `dispatch_issues({project, status: "triage", label: "legion", limit: 250})`.
102
- When its first line ends `(showing N of M)`, it is one page: say in your summary how many rows
103
- it left unread. The rows show no parent, so open each row with `dispatch_read`: one whose
104
- `Links:` name a `child_of` issue is a child, which its parent's architect owns, so leave it,
105
- whether or not `legion state --json` records it (a `child_of` under `Referenced by:` is a child
106
- of this issue, not its parent). Of the rest, triage each that `legion state --json` does not
107
- record under `issues` as a new issue. A root recorded there and now in `triage` is work the
108
- daemon holds that a human pulled back: never re-admit it yourself; name it in your summary to
109
- the human ("<KEY> was pulled back to triage; what do you want?").
102
+ `dispatch_issues({project, status: "triage", label: "legion", limit: 250, offset: 0})`.
103
+ When its first line ends `(showing 1-250 of N)`, read the next page with `offset: 250`, and so
104
+ on until you have all N rows. The rows show no parent, so open each row with `dispatch_read`
105
+ and follow `Links:` up through each `child_of` parent (a `child_of` under `Referenced by:` is a
106
+ child of this issue, not its parent). Leave a child that has an ancestor in `admission.active`
107
+ or `admission.waiting`: that tree's architect owns it. Triage every other row as a root,
108
+ children outside a live tree included, when `legion state --json` does not record it under
109
+ `issues`. A root recorded there and now in `triage` is work the daemon holds that a human
110
+ pulled back: never re-admit it yourself; name it in your summary to the human ("<KEY> was
111
+ pulled back to triage; what do you want?").
112
+ 3. Fill the free admission slots ([Keeping the slots full](#keeping-the-slots-full-go-daemon)).
113
+ 4. Post the day's report when this is your first turn of the UTC day
114
+ ([Daily report](#daily-report-go-daemon)).
110
115
 
111
116
  The issue record and Dispatch are the truth; the topic is the wake.
112
117
 
@@ -117,12 +122,141 @@ since its project may be shared with humans and other agents. It never admits a
117
122
  without the label, and wakes you for a root in `triage` only while the root carries it and is
118
123
  unrecorded: on its creation with the label, and on each change to it after that while it stays
119
124
  in triage, the change that adds the label included (the dashboard creates an issue without
120
- labels, so a human adds it from the issue header). Leave an issue without the label alone:
121
- handing work to Legion is its owner's decision, so never triage, park, label, or comment on it.
122
- A child needs no label: it runs under its tree's architect once its root is admitted.
123
- `legion status <KEY> todo` admits a root only while it carries the label, so a root you file for
124
- Legion to run carries it (`labels: ["legion"]` in `dispatch_issue`). Taking the label off a
125
- waiting root drops it from the waiting line; taking it off an admitted tree does not stop it.
125
+ labels, so a human adds it from the issue header). Two parties hand work over: a person, who sets
126
+ the label from the issue header, and you, when you fill a free slot (below). You label an issue
127
+ only when you take it or file it for Legion, so the label means Legion has the issue or had it.
128
+ A person's label is their decision: never take it off. A child needs no label: it runs under its
129
+ tree's architect once its root is admitted. `legion status <KEY> todo` admits a root, or a child
130
+ outside a live tree (admitted as a root of its own), only while it carries the label, so an issue
131
+ you hand over or file for Legion to run carries it first (`labels` in `dispatch_issue_update` or
132
+ `dispatch_issue`). Taking the label off a waiting root drops it from the waiting line; taking it
133
+ off an admitted tree does not stop it.
134
+
135
+ ## Keeping the slots full (Go daemon)
136
+
137
+ Picking the next work is your job: nobody hand-feeds issues to Legion. Keep every admission slot
138
+ filled with the highest-priority concrete issue Legion can take. The unit of Legion work is a
139
+ leaf, an issue with no children, never an umbrella that holds other issues.
140
+
141
+ **When.** At every start (step 3 above), and on each `slot-free on <KEY>` wake: the daemon
142
+ released `<KEY>`'s slot, because its tree finished or left the workflow, and no waiting root took
143
+ it.
144
+
145
+ **Scope first.** The scope the deployment instructions state decides which issues are candidates
146
+ at all, before anything below. When they say you hand Legion no issue yourself, or that Legion
147
+ runs only issues someone else sets to `todo`, the walk takes nothing: stop here, whatever slots
148
+ are free. When they narrow the scope (a repository, a kind of change), a candidate outside it is
149
+ skipped (the last row of the table below). With no scope stated, every issue of the project is in
150
+ scope.
151
+
152
+ **How many.** Read `legion state --json`. The free slots are `admission.cap` minus the roots in
153
+ `admission.active` and in `admission.waiting` (a waiting root takes the next slot before anything
154
+ you add). With none free, stop.
155
+
156
+ **Candidates.** The project's open `todo` issues, roots and children alike, that have no children
157
+ at all and do not carry the `legion` label. `todo` alone, as Sami ruled for choosing work on
158
+ 2026-09-27 (`skill://dispatch`, "Choosing what to work on"): take the top ready issue, "status
159
+ `todo`, highest priority first, then board rank"; an issue that waits on a deploy or a decision
160
+ belongs in `backlog`, so the walk takes nothing from `backlog` or `triage`. The Go daemon runs
161
+ only labelled roots, so every root it ran since the daemon required the label carries it: a
162
+ labelled root in `todo` is the daemon's to admit or queue, one in `triage` is yours to triage
163
+ (step 2 above), and one anywhere else was parked by Legion or by a person. A child you take becomes
164
+ a root of its own: the daemon admits a labelled `todo` child whose tree Legion does not run as a
165
+ new tree. The `dispatch_issues` rows show no labels and no children, so the listing below filters
166
+ neither: both are checked on each candidate (the table's first rows). List the `todo` issues one
167
+ priority at a time, `priority: [0]` first, then `[1]`, `[2]`, `[3]`, and `[null]` (no priority)
168
+ last, keeping the listing's order within each, which is the board's rank:
169
+
170
+ ```text
171
+ dispatch_issues({ project: "<PROJECT>", status: "todo", priority: [0], limit: 250, offset: 0 })
172
+ ```
173
+
174
+ When the first line ends `(showing 1-250 of N)`, the next page is `offset: 250`, then `500`. Read
175
+ pages only as far as you need: stop listing once the free slots are filled. `<PROJECT>` is the
176
+ Dispatch project key, the prefix of this deployment's issue keys (`AGENTC-12` → `AGENTC`), which is
177
+ also `daemon.project` in `legion state --json`: the project key exactly as `legion.yaml` writes it.
178
+ A row that shows `claimed by …` and does not end its claim with `· not running` (the route, when
179
+ the row shows one, comes after the claim) is claimed, as the table below says: skip it without
180
+ reading it.
181
+
182
+ **Walk.** Take the remaining rows in that order until the free slots are filled. Read each one with
183
+ `dispatch_read({ issue: "<KEY>" })` and skip it when any of these holds. `dispatch_read` shows only
184
+ the issue's last 10 events, so the rows that read `Events:` are best-effort: the pull-request row
185
+ leans on `External links:`, and the label row is the one that never depends on history.
186
+
187
+ | Skip when | How you check it |
188
+ |---|---|
189
+ | It carries the `legion` label | `Labels:` lists `legion`, in any case. Legion has the issue or had it, as **Candidates** above says. |
190
+ | It has any child | `dispatch_read({ ref: "dispatch://<KEY>/children" })` lists any child, open or `done`. It is an umbrella, and a finished umbrella is still no leaf. That also skips an issue whose only child is done, which is accepted. Its open children are candidates themselves, each in its own place in the order. |
191
+ | An ancestor is Legion's | Follow `Links:` up through each `child_of` parent, reading each one, and skip when any ancestor carries the `legion` label or is recorded under `issues` in `legion state --json`, whatever its status: a Legion tree, running or parked, owns its children. An ancestor's claim or route does not skip the issue: a coordinator holding an umbrella files `todo` leaves for others to pick up, and the claim on the issue itself is what keeps two sessions off the same work. A `child_of` under `Referenced by:` is a child of this issue, not its parent. |
192
+ | Someone is designing it | `Open asks:` lists any ask, a `Spec approval: awaiting …` line shows the spec waits on a human, or `Events:` show an `artifact.version` or an `ask.opened` from the last seven days: a session or a person is shaping it even when nobody claims or routes it. |
193
+ | Legion ran it without the label now on it | `legion state --json` records it under `issues`, whatever its status, or `Events:` show a status write by `session legion-daemon:<PROJECT>`, the daemon's actor on every `legion status` (yours included) and on its own `in_progress` at admission. That covers a root a person took the label off, and one that ran before the daemon required the label and never had it. Name each one you skip for this in your summary. The walk never sends a root Legion already ran back into Legion: a person does that with the label and `todo`, and you do it only when a wake below says to (`worker-died`, closed-tree activity). |
194
+ | A running session or a person claims it | `Claimed by:` names anyone and does not end `· not running`. `· liveness unknown` counts as claimed: the agent registry could not be read, so nothing says the holder stopped. A claim ending `· not running` has lapsed, and the issue is free. |
195
+ | Its route reaches a running session | `Route:` names a route with nothing after it, or with `(held by …)`. `(nobody holds it right now)` and `(that session is not running right now)` reach nobody; `(the Envoy listener did not answer, …)` counts as reaching someone. `Route: none` is free. |
196
+ | A pull request is linked or named | `External links:` lists a pull request (kind `github_pr`, or a URL ending `/pull/<n>`), or a comment or message among `Events:` names one. You cannot read GitHub, so an open, merged, or closed pull request all count. A person who wants Legion on it anyway hands it over themselves: the label, then `todo`. |
197
+ | Its assignee is working it | `Assignee:` names a person who holds the claim (the row above), or whose own comment or message among `Events:` says they are working on it. The assignee alone is who answers the issue's questions, not who works it. |
198
+ | It is outside this deployment's scope | Read the scope the deployment instructions state against the title and, when the title does not settle it, the spec (`dispatch_doc_read({ issue: "<KEY>" })`). When in doubt, skip it. With no scope stated, every issue of the project is in scope. |
199
+
200
+ **Take.** For each candidate that passes, in order:
201
+
202
+ 1. Add the label and keep the labels it has, which `Labels:` lists (`none` is no labels). `labels`
203
+ replaces the whole set, so a label you leave out is removed.
204
+
205
+ ```text
206
+ dispatch_issue_update({ issue: "<KEY>", labels: ["<each current label>", "legion"] })
207
+ ```
208
+
209
+ 2. It is already in `todo`, so the label admits it: the daemon records it and gives it the free
210
+ slot, or queues it in `admission.waiting`. It needs no status write.
211
+ 3. Post one short comment that says Legion took it and names who is asked at its design gate, from
212
+ the `Assignee:` line, with the assignee sentence [New issue triage](#new-issue-triage) step 4
213
+ gives:
214
+
215
+ ```text
216
+ dispatch_comment({ issue: "<KEY>", body: "Legion took this issue: it was the highest-priority open issue nobody else was working on. Assigned to <login>, who will get this tree's questions and its design approval." })
217
+ ```
218
+
219
+ The daemon admits each root when Dispatch's event reaches it; the next `legion state --json`
220
+ shows it in `admission.active`. When the candidates run out with slots still free, leave those
221
+ slots free and say so in your summary.
222
+
223
+ ## Daily report (Go daemon)
224
+
225
+ Once a day, post one Dispatch message that says what Legion finished, what it closed without a
226
+ change and why, and what is running.
227
+
228
+ **Where.** On the issue the deployment instructions name for Legion's reports. With none named,
229
+ on the project's `Legion daily report` issue:
230
+ `dispatch_search({ query: "\"Legion daily report\"", project: "<PROJECT>" })` finds it. When it
231
+ does not exist, create it once and park it in `icebox`, so nobody takes it as work; it never
232
+ carries the `legion` label:
233
+
234
+ ```text
235
+ dispatch_issue({ project: "<PROJECT>", title: "Legion daily report", spec: "## Summary\n\nLegion's controller posts one message here each day: what Legion finished, what it closed without a change and why, and what is running. This issue is not work, so it carries no `legion` label and stays in icebox." })
236
+ legion status <report KEY> icebox
237
+ ```
238
+
239
+ **When.** On your first turn of each UTC day, whatever it is: your start, or a wake of any kind.
240
+ Read the report issue with `dispatch_read`, and post when its `Events:` show no `message.created`
241
+ from today. You have no clock of your own, so a day with no turn has no report; the next one
242
+ covers it.
243
+
244
+ **What.** One `dispatch_message({ issue: "<report KEY>", body })` of at most 2,000 characters,
245
+ written as `skill://dispatch`'s "Writing for the human" says: every issue by its key and title,
246
+ every pull request by its URL.
247
+
248
+ - **Finished.** `dispatch_issues({ project: "<PROJECT>", status: "done", updated_since: "<the
249
+ previous report's time, or 24 hours ago>", limit: 250 })`, read every page, and keep the issues
250
+ `legion state --json` records under `issues` whose `issue.closed` event is after the previous
251
+ report. For each, `dispatch_read` it: the pull request under `External links:` is the one that
252
+ merged.
253
+ - **Closed without a change.** Those with no pull request, each with the reason its closing
254
+ message gave (the `message.created` just before `issue.closed` among `Events:`).
255
+ - **Running.** Each root in `admission.active` with its `issues.<KEY>.phase`, and the roots in
256
+ `admission.waiting`.
257
+
258
+ A day with nothing finished says so in one sentence. When the lists do not fit, keep the counts
259
+ and the highest-priority issues.
126
260
 
127
261
  ## Deployment instructions
128
262
 
@@ -136,7 +270,8 @@ quoted here.
136
270
  - **Direct user message always first.** If this turn includes a direct user message, answer
137
271
  it before handling every other wake.
138
272
  - **One wake = one turn.** Handle exactly the wake's implication, then end the turn. Never
139
- poll, idle-loop, or wait for another event.
273
+ poll, idle-loop, or wait for another event. The one addition: your first turn of each UTC day,
274
+ whatever woke you, also posts the day's report ([Daily report](#daily-report-go-daemon)).
140
275
  - **Wakes are advisory.** Before any side effect, verify the current daemon state and the
141
276
  relevant Dispatch issue. A stale or duplicate wake may cost a read, never a wrong action.
142
277
  - **Controller state is disposable.** Do not reconstruct or preserve local controller
@@ -151,7 +286,8 @@ quoted here.
151
286
  | Wake | Content | Controller action |
152
287
  |---|---|---|
153
288
  | New issue created in the Dispatch project (`issue.created`, status `triage`; under the TypeScript daemon resync heals misses, under the Go daemon the boot step above does). From the Go daemon: `triage on <KEY>` (payload `{kind: "triage"}`) on the controller topic, for an unrecorded root carrying the `legion` label only ("Issues handed to Legion" above) | issue key + triage context (incl. pre-existing children) | Triage: `legion status <KEY> todo` to admit, or set `backlog`/`icebox` to park |
154
- | Backlog eligibility | slot freed / priority change | Reconsider parked items and move the eligible root to `todo` |
289
+ | Backlog eligibility (TypeScript daemon) | slot freed / priority change | Reconsider parked items and move the eligible root to `todo` |
290
+ | `slot-free on <KEY>` from the Go daemon (payload `{kind: "slot-free"}`) | the root whose slot the daemon released with no waiting root to take it | Verify a free slot in `legion state --json`, then fill it ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
155
291
  | 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 |
156
292
  | Resync report | artifact-driven anomaly list (zero-owner trees, untriaged-open, launch-failed, admission-drift) | Verify against fresh state, then heal |
157
293
  | 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) |
@@ -187,19 +323,30 @@ quoted here.
187
323
 
188
324
  (or `icebox` for longer-term deferral). Dispatch status is the durable record; there is no
189
325
  other marker to maintain, beyond the Go daemon's `legion` label, which parking leaves in
190
- place. Do not triage a system-created child as a root issue.
326
+ place. Under the Go daemon a root never waits for capacity in `backlog`: in `todo` the
327
+ daemon's admission queue holds it (`admission.waiting`), so park only a root that should not
328
+ run now. Do not triage a system-created child as a root issue.
191
329
  4. When you post a triage note (a `dispatch_comment` on the issue saying what you decided and
192
- why), name who will be asked: `Assigned to <login>, who will get this tree's questions`,
193
- or, when the `Assignee:` line says `unassigned`, `Unassigned — nobody's Inbox shows this
194
- tree's questions until someone takes it from the issue header (Assignee, beside Priority)`.
195
- An unassigned root still runs; the architect's asks wait in every Inbox's Unassigned band.
330
+ why), name who will be asked with the assignee sentence: `Assigned to <login>, who will get
331
+ this tree's questions and its design approval`, or, when the `Assignee:` line says
332
+ `unassigned`, `Unassigned — nobody's Inbox shows this tree's questions or its design approval
333
+ until someone takes it from the issue header (Assignee, beside Priority)`. An unassigned root
334
+ still runs; the architect's asks wait in every Inbox's Unassigned band.
196
335
 
197
336
  ## Backlog eligibility
198
337
 
199
- When a slot frees or priority changes, use `legion state --json` and the current Dispatch
200
- issue to reconsider parked roots. Admit the selected root with
201
- `legion status <KEY> todo`. Moving an item to or from `backlog`/
202
- `icebox` is a deliberate controller decision, not a no-op.
338
+ Under the Go daemon nothing reconsiders `backlog` or `icebox` on its own: [Keeping the slots
339
+ full](#keeping-the-slots-full-go-daemon) takes only `todo` issues, since an issue that waits on a
340
+ deploy or a decision belongs in `backlog` (Sami's ruling of 2026-09-27, `skill://dispatch`,
341
+ "Choosing what to work on"). A handed-over root waits for a slot in `todo`, where the daemon's
342
+ admission queue holds it (`admission.waiting`), so a park means "should not run now", and a
343
+ parked root keeps its `legion` label. A parked issue runs again when a person sets it to `todo`,
344
+ or when a wake tells you to re-admit it (`worker-died`, closed-tree activity).
345
+
346
+ Under the TypeScript daemon, when a slot frees or priority changes, use `legion state --json` and
347
+ the current Dispatch issue to reconsider parked roots. Admit the selected root with
348
+ `legion status <KEY> todo`. Moving an item to or from `backlog`/`icebox` is a deliberate
349
+ controller decision, not a no-op.
203
350
 
204
351
  ## Architect escalation
205
352
 
@@ -212,8 +359,10 @@ and the Dispatch issue. If the work belongs in an independent root:
212
359
 
213
360
  1. File a **fresh root issue** with `dispatch_issue({ project, title, spec })` (no `parent`;
214
361
  under the Go daemon add `labels: ["legion"]`, without which it is never admitted).
215
- `project` is the issue key's prefix before `-<n>` (e.g. `LEGSMOKE-3` → `LEGSMOKE`) — not
216
- the role-token `<project>` (the daemon's own project, e.g. `acme`), a different string.
362
+ `project` is the issue key's prefix before `-<n>` (e.g. `LEGSMOKE-3` → `LEGSMOKE`): the
363
+ project key exactly as `legion.yaml` writes it, which `legion state --json` shows as
364
+ `daemon.project`. It is not the lowercase project token in role names such as
365
+ `legion-<project>-controller`.
217
366
  2. Park the child (`legion status <child> icebox`) and leave
218
367
  a pointer to the new root issue. The controller's capability is `todo`/`backlog`/`icebox`
219
368
  only — only the owning architect or the daemon closes an issue as `done`.