@sjawhar/pi-legion 10.29.1 → 10.30.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/legion.js CHANGED
@@ -14445,6 +14445,7 @@ var SEARCH_QUERY_HINT = "search with a short phrase of a few words, not a passag
14445
14445
  var SEARCH_KIND_DEPTH = 100;
14446
14446
  var PROJECT_KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
14447
14447
  var PROJECT_KEY_RULE = "an uppercase letter, then 1 to 9 uppercase letters or digits, such as CORE";
14448
+ var ARCHITECTURE_SOURCE_REPO_PATTERN = /^[A-Za-z0-9-]+\/(?!\.\.?$)[A-Za-z0-9._-]+$/;
14448
14449
  var ISSUE_STATUSES = [
14449
14450
  "triage",
14450
14451
  "icebox",
@@ -14810,7 +14811,7 @@ var dispatchToolSpecs = [
14810
14811
  {
14811
14812
  name: "dispatch_issues",
14812
14813
  example: { project: "PROJ", limit: 250, offset: 250 },
14813
- description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never.",
14814
+ description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never. " + "all takes the whole filtered list in one read instead, for a caller that must not miss or " + "repeat a row while other writers change the list, and especially for one whose own writes " + "move issues out of what it filtered on.",
14814
14815
  arguments: (z2) => ({
14815
14816
  project: z2.string().describe("Project key to list issues from."),
14816
14817
  status: z2.enum(ISSUE_STATUSES).describe("Optional lifecycle status filter.").optional(),
@@ -14820,7 +14821,8 @@ var dispatchToolSpecs = [
14820
14821
  updated_since: z2.string().describe("Optional RFC3339 timestamp; only issues updated at or after it.").optional(),
14821
14822
  route_status: z2.enum(ISSUE_ROUTE_STATUSES).describe("Optional: only open issues whose route is in this state. live: a running session holds " + "the role or is the routed session. no_holder: nobody running holds the role, or the " + "session is not running, right now. unknown: the Envoy listener did not answer.").optional(),
14822
14823
  limit: z2.number({ int: true, min: 1, max: MAX_ISSUE_PAGE_LIMIT }).describe(`Maximum rows, 1-${MAX_ISSUE_PAGE_LIMIT}; default ${DEFAULT_ISSUE_PAGE_LIMIT}.`).optional(),
14823
- offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional()
14824
+ offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional(),
14825
+ all: z2.boolean().describe("Take every matching issue in one unpaged read, so no row is missed or repeated while " + "the list changes. Refused with limit or offset.").optional()
14824
14826
  })
14825
14827
  },
14826
14828
  {
@@ -14842,11 +14844,19 @@ var dispatchToolSpecs = [
14842
14844
  },
14843
14845
  {
14844
14846
  name: "dispatch_architecture_sync",
14845
- example: { project: "CORE" },
14846
- description: "Import a project's architecture model from its configured source repository now, instead of " + "waiting for the server's five-minute schedule. Returns the imported commit and component " + "count, or the recorded error when the model was rejected (the previous model stays up). " + "The source itself is configured by a human in Settings; 404 SOURCE_NOT_FOUND without one.",
14847
+ example: { project: "CORE", repo: "acme/infra" },
14848
+ description: "Import a project's architecture model from its source repositories now, instead of waiting " + "for the server's five-minute schedule. A project may have several sources, one per " + "repository, and its model is every source's components together. Without repo every " + "source imports and each answers on its own line; with repo only that source does. Each " + "line names the imported commit, or the recorded error when that source's model was " + "rejected (its previous components stay up, and every other source is untouched). Sources " + "are saved in Settings or with PUT /api/v1/projects/{key}/architecture-sources/{owner}/{repo}; " + "404 SOURCE_NOT_FOUND when the project has none, or none for that repository.",
14847
14849
  arguments: (z2) => ({
14848
- project: z2.string().describe("Project key whose architecture source to sync, such as CORE.")
14850
+ project: z2.string().describe("Project key whose architecture sources to sync, such as CORE."),
14851
+ repo: z2.string().describe("One source repository of the project to sync alone, owner/name; omit it to sync every source.").optional()
14849
14852
  }),
14853
+ validation: {
14854
+ check: (value) => {
14855
+ const { repo } = value;
14856
+ return typeof repo !== "string" || ARCHITECTURE_SOURCE_REPO_PATTERN.test(repo);
14857
+ },
14858
+ message: "repo is not a repository: give it as owner/name, such as acme/infra."
14859
+ },
14850
14860
  strict: true
14851
14861
  },
14852
14862
  {
@@ -15063,6 +15073,9 @@ function asErrorShape(value) {
15063
15073
  function isJson(response) {
15064
15074
  return response.headers.get("content-type")?.includes("application/json") ?? false;
15065
15075
  }
15076
+ function isNoRoute(error48) {
15077
+ return error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "NOT_FOUND" && error48.message.startsWith("no route for ");
15078
+ }
15066
15079
  var ISSUE_EVENTS_PAGE_MAX = 200;
15067
15080
  var DISPATCH_TOOL_DEADLINE_MS = 60000;
15068
15081
  function requestSignal(signal) {
@@ -15090,6 +15103,18 @@ function whyNotAPage(answer) {
15090
15103
  }
15091
15104
  return `${what}. Retrying will not help: the same request gets the same answer until that ` + "Dispatch is upgraded or fixed.";
15092
15105
  }
15106
+ function whyNotAnArray(answer) {
15107
+ if (typeof answer === "string")
15108
+ return `text that is not JSON, ${GATEWAY_PAGE}, so a retry may succeed`;
15109
+ if (answer === null)
15110
+ return "null";
15111
+ if (typeof answer !== "object")
15112
+ return `a ${typeof answer}`;
15113
+ if ("issues" in answer && Array.isArray(answer.issues)) {
15114
+ return "a page, so this Dispatch pages a request that carries no limit and no offset";
15115
+ }
15116
+ return "an object that is not the listing";
15117
+ }
15093
15118
  var GATEWAY_PAGE = "which looks like a proxy or gateway page rather than Dispatch's own answer";
15094
15119
  function gatewayAdvice(method, status) {
15095
15120
  if (!transientStatus(status)) {
@@ -15165,6 +15190,14 @@ class DispatchClient {
15165
15190
  return answer;
15166
15191
  throw new Error(`GET ${this.#url(path2, query)} asked for a page ({issues, total, limit, offset}) and got ` + refusal);
15167
15192
  }
15193
+ async listAllIssues(options) {
15194
+ const path2 = ["api", "v1", "issues"];
15195
+ const query = { ...options };
15196
+ const answer = await this.#json("GET", path2, undefined, query);
15197
+ if (Array.isArray(answer))
15198
+ return answer;
15199
+ throw new Error(`GET ${this.#url(path2, query)} asked for the unpaged listing and got ${whyNotAnArray(answer)}.`);
15200
+ }
15168
15201
  async listProjectArtifacts(project, unlinked = false) {
15169
15202
  return this.#json("GET", ["api", "v1", "projects", project, "artifacts"], undefined, unlinked ? { unlinked: "true" } : undefined);
15170
15203
  }
@@ -15253,19 +15286,69 @@ class DispatchClient {
15253
15286
  async listProjects() {
15254
15287
  return this.#json("GET", ["api", "v1", "projects"]);
15255
15288
  }
15256
- async syncArchitectureSource(project) {
15257
- return this.#json("POST", ["api", "v1", "projects", project, "architecture-source", "sync"]);
15258
- }
15259
- async getArchitectureSource(project) {
15289
+ async listArchitectureSources(project) {
15290
+ try {
15291
+ return await this.#json("GET", ["api", "v1", "projects", project, "architecture-sources"]);
15292
+ } catch (error48) {
15293
+ if (!isNoRoute(error48))
15294
+ throw error48;
15295
+ }
15260
15296
  try {
15261
- return await this.#json("GET", ["api", "v1", "projects", project, "architecture-source"]);
15297
+ const source = await this.#json("GET", [
15298
+ "api",
15299
+ "v1",
15300
+ "projects",
15301
+ project,
15302
+ "architecture-source"
15303
+ ]);
15304
+ return source === null ? [] : [source];
15262
15305
  } catch (error48) {
15263
15306
  if (error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "SOURCE_NOT_FOUND") {
15264
- return null;
15307
+ return [];
15265
15308
  }
15266
15309
  throw error48;
15267
15310
  }
15268
15311
  }
15312
+ async syncArchitectureSources(project) {
15313
+ try {
15314
+ return await this.#json("POST", [
15315
+ "api",
15316
+ "v1",
15317
+ "projects",
15318
+ project,
15319
+ "architecture-sources",
15320
+ "sync"
15321
+ ]);
15322
+ } catch (error48) {
15323
+ if (!isNoRoute(error48))
15324
+ throw error48;
15325
+ }
15326
+ const source = await this.#json("POST", [
15327
+ "api",
15328
+ "v1",
15329
+ "projects",
15330
+ project,
15331
+ "architecture-source",
15332
+ "sync"
15333
+ ]);
15334
+ return [source];
15335
+ }
15336
+ async syncArchitectureSource(project, repo2) {
15337
+ const [owner, name, ...rest] = repo2.split("/");
15338
+ if (!owner || !name || rest.length > 0) {
15339
+ throw new Error(`repository must be owner/name, got ${JSON.stringify(repo2)}`);
15340
+ }
15341
+ return this.#json("POST", [
15342
+ "api",
15343
+ "v1",
15344
+ "projects",
15345
+ project,
15346
+ "architecture-sources",
15347
+ owner,
15348
+ name,
15349
+ "sync"
15350
+ ]);
15351
+ }
15269
15352
  async resolveAsk(id, input) {
15270
15353
  return this.#json("POST", ["api", "v1", "asks", id, "resolve"], input);
15271
15354
  }
@@ -16956,7 +17039,7 @@ async function bootLegionClaim(host2) {
16956
17039
  // package.json
16957
17040
  var package_default = {
16958
17041
  name: "@sjawhar/pi-legion",
16959
- version: "10.29.1",
17042
+ version: "10.30.0",
16960
17043
  license: "Apache-2.0",
16961
17044
  type: "module",
16962
17045
  omp: {
@@ -16968,7 +17051,7 @@ var package_default = {
16968
17051
  ]
16969
17052
  },
16970
17053
  legion: {
16971
- daemonApiVersion: 36
17054
+ daemonApiVersion: 37
16972
17055
  },
16973
17056
  repository: {
16974
17057
  type: "git",
@@ -120,8 +120,9 @@ The merge closes the issue: when GitHub reports the pull request merged while th
120
120
  `pr-merged` notice names what it waits for and none follows as they depart, so read the pull
121
121
  request on GitHub.
122
122
  - A child's close reaches you as `child-closed`. The daemon retires five of its six claims and
123
- suspends its implementer, session and volume kept, because it still owes the production check
124
- (`skill://legion-worker/references/merge-gate.md`, *The production check after the close*).
123
+ leaves the implementer running, because it still owes the production check. When the issue's
124
+ pod and Sandbox go is in `skill://legion-worker/references/merge-gate.md`, *The production check
125
+ after the close*.
125
126
  - A root's close writes `done`, frees the tree's admission slot and lingers the tree for
126
127
  `linger_hours` with every member suspended. The linger's end, the tree's last step, waits until
127
128
  every member's open production check has closed.
@@ -61,17 +61,21 @@ repositories with your gh's read-only access (`-R owner/repo`).
61
61
 
62
62
  ## Architecture components
63
63
 
64
- A project's architecture source is the repository whose `.dispatch/architecture/` files Dispatch
65
- imports as its components (`skill://dispatch`, "Architecture components").
64
+ A project's architecture sources are the repositories whose `.dispatch/architecture/` files
65
+ Dispatch imports as its components (`skill://dispatch`, "Architecture components").
66
66
 
67
67
  - **The components exist:** attach the root before you decompose, so every child inherits it:
68
68
  `dispatch issue-update --issue <root> --components-json '{"mode":"explicit","ids":[…]}'`.
69
69
  - **They do not exist yet (bootstrap):** in the root's first PR (the implement worker pushes it),
70
70
  write `.dispatch/architecture/<id>.md` files for the planned components — front matter `title`,
71
- `parent`, `depends_on`, `external`; no `paths` yet. The importer reads the source branch's head,
72
- so leave the root on `inherit` until that PR has merged: an attach before then answers
71
+ `parent`, `depends_on`, `external`; no `paths` yet. A project may have several sources, one per
72
+ repository, and its model is all of their components together: the file goes in the branch of
73
+ the source whose repository holds it, `parent` and `depends_on` name components of that same
74
+ source only, and its id must be one no other source of the project uses (a repeated id fails that
75
+ source's import, naming the repository that has it). The importer reads the source branch's
76
+ head, so leave the root on `inherit` until that PR has merged: an attach before then answers
73
77
  `400 COMPONENTS_INPUT`, since the component does not exist. After the merge, run
74
- `dispatch architecture-sync --project <KEY>` and attach the root; children created before then
75
- inherit it.
78
+ `dispatch architecture-sync --project <KEY>` (every source; `--repo <owner/name>` syncs that one
79
+ alone) and attach the root; children created before then inherit it.
76
80
  - Give a child its own `--components-json` only when it changes a narrower set, and
77
81
  `{"mode":"none","reason":"…"}` when it changes no code or file a component describes.
@@ -26,7 +26,7 @@ and 7 (a conflict, the hand-over, the close) is in
26
26
  | `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. |
27
27
  | `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. |
28
28
  | `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 committed handoff for the finishing phase; 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, Acceptance, scope and 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` or send it back with `request_backward_move`. A `tester` notice with `verdict: "fail"` — its handoff carries `implementerProof.verdict: "rejected"`, or a failure naming the production-like proof — has gone back to the **implementer** by the daemon's table; never supply the proof 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: file a child issue in this tree to build the surface (infrastructure, tooling, or a skill), and when that child's `child-closed` arrives, tell the waiting worker to continue with `envoy_publish` to its role topic. That report is never a reason to advance the phase. |
29
- | `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 that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. 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, the tester's red tests and handoff 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 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; or file a corrective child. 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 acceptance lines the next round answers; a correctness finding is still fixed in this pull request, and only naming, duplication or wording cleanup moves to the `Fast-follow:` line. **Split**: part of the work cannot settle in this pull request. On a child, file that part as a child of this issue's parent, never of this one (a parent waits in `accepting` for every child, 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 -`, released with `release_children` once this issue closes. 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 accepts 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. |
29
+ | `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 that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. 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 and handoff 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; or file a corrective child. 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 acceptance lines the next round answers; a correctness finding is still fixed in this pull request, and only naming, duplication or wording cleanup moves to the `Fast-follow:` line. **Split**: part of the work cannot settle in this pull request. On a child, file that part as a child of this issue's parent, never of this one (a parent waits in `accepting` for every child, 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 -`, released with `release_children` once this issue closes. 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 accepts 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. |
30
30
  | `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`, slot and workers kept, 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. |
31
31
  | `pr-closed-unmerged` | Decide from current scope whether the work is reopened, started over (`park_child` then `rerun_child`, for a child), or ended with a reason. Delegate the repository action to the responsible phase worker and keep ownership. |
32
32
  | `merge-refused` | GitHub refused the daemon's submission of READY's merge in a way a retry would meet again, so it submits that merge no further: ask a person with `dispatch ask` to fix the repository setting or App permission and merge at READY's head (`skill://legion-architect/references/merge-notices.md#merge-refused`). |
@@ -11,7 +11,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
11
11
 
12
12
  ## Every phase, in this order
13
13
 
14
- 1. **Read first.** Read the issue, then each committed handoff before your phase, with `read` from `$LEGION_WORKSPACE/.legion/<issue>/`, in order: `plan.json`, `implement.json`, `test.json`, `review.json`. Where one disagrees with your memory or a transcript, the file wins: it is the copy that survived. At each later assignment read them again, your own included, since other phases may have committed since. After a `Your workspace was recreated…` message, read `.legion/<issue>/workspace-recovered.json` too, once.
14
+ 1. **Read first.** Read the issue, then each committed handoff before your phase, with `read` from `$LEGION_WORKSPACE/.legion/<issue>/`, in order: `plan.json`, `implement.json`, `test.json`, `review.json`. Where one disagrees with your memory or a transcript, the file wins: it is the copy that survived. At each later assignment read them again, your own included, since other phases may have committed since. After a `Your workspace was recreated…` message, anything unpushed is gone, and no file in the workspace says so.
15
15
  2. **Commit only your phase's work**, with `jj -R "$LEGION_WORKSPACE" split -m "<message>" <paths…>`, each commit holding only your phase's paths, so the next phase sees what you changed.
16
16
  3. **Put back what you copied in.** Before you write the handoff, run `jj -R "$LEGION_WORKSPACE" status` and remove or restore every file you copied or fetched in that is not your phase's work, such as another branch's test rig. With jj the working copy is the commit: the next role's first commit builds on what you left, and its push ships it.
17
17
  4. **Write the handoff** your role prompt names. Each `proof` entry, the implementer's or the tester's, is an object of six non-empty strings: `criterion` (the acceptance line it proves), `surface` (where a user reaches the change), `command`, `observed`, `headSha` (the commit it ran at) and `negativeControl` (a deliberately broken input and the refusal seen); `skill://legion-worker/references/pr-body.md#what-a-proof-is` defines each. To add to a handoff already on disk, `read` it, edit it, and `write` it whole with `completed` set to now.
@@ -251,16 +251,17 @@ reviewing until you finish.
251
251
  topic, and file no issue. It names this issue, the pull request, the merge commit, what you
252
252
  drove and what failed. Name the ask (`dispatch://<REPORT KEY>/ask/<id>`) in the `Production:`
253
253
  line and in the summary.
254
- - **Nothing waits in the session for a deploy.** A `pending` ends the look: the daemon suspends
255
- your claim with its session and the issue's volume kept, the pod goes, and the daemon wakes the
256
- check (the pod comes back and your session resumes with a new check task) on Dispatch's
254
+ - **Nothing waits in the session for a deploy.** A `pending` ends the look. Where no wake is
255
+ queued yet, the daemon suspends your claim with its session kept, and the issue's Sandbox goes
256
+ with its pod and disk; where a wake arrived while the look was open, the next look starts at once
257
+ on the same pod. Either way the daemon wakes the check (after a suspension a new pod comes back
258
+ on a fresh disk, and your session resumes with a new check task) on Dispatch's
257
259
  `issue.deployed` event for the pull request; when the project's `legion.yaml` names the
258
260
  repository's deploy workflow (`projects.<KEY>.deploys`), at each completed run of that workflow
259
261
  on the default branch whose named step (such as `Apply`) succeeded after the merge, once per such
260
262
  job; or, for `sjawhar/legion`, on a Legion daemon's boot on a build containing the merge, on
261
263
  Dispatch's `server.started` naming such a build, or on the build the daemon read from Dispatch at
262
- its boot. A wake while a look is open is kept, and a `pending` then starts the next look at once.
263
- The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
264
+ its boot. The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
264
265
  `the deploy workflow's run: <repo> <workflow> run <id> attempt <n>, job <job name>: <step> succeeded at <t>`,
265
266
  `the Legion daemon's boot on build <sha>`, `Dispatch's start on build <sha>`,
266
267
  `Dispatch's build <sha>`), and its `Production:` line names it too, so the pull request carries
@@ -279,5 +280,6 @@ reviewing until you finish.
279
280
  on a closed issue (`ISSUE_CLOSED`), so the deployment instructions' "on the pull request and the
280
281
  issue" is the pull request for a closed issue, and a defect reaches Dispatch as the ask above.
281
282
  The architect accepted the issue before the merge and is asked nothing after it. `pass` or
282
- `fail` closes the check: your claim is retired and, for a child, the issue's Sandbox and volume
283
- are released.
283
+ `fail` closes the check: your claim is retired and the issue's Sandbox goes with its pod and
284
+ disk, for a root as for a child. Neither ending retires or suspends anything of an issue back in
285
+ the workflow (a root reopened inside its linger): its claim and pod are the new run's.
@@ -86,11 +86,11 @@ for it*.
86
86
 
87
87
  An install of your own — an interpreter, a browser, a second toolchain, an extraction prefix — is
88
88
  on the sanctioned side when both of these hold: what it writes lands on the issue's own volume,
89
- under `/legion`, which outlives the pod and is shared by the issue's roles — whether through a
90
- variable the runtime sets for you there or under a path on that volume your harness names
91
- itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under `/legion/workspaces`) and
92
- the runtime's own directories (`/legion/repos`, `/legion/feed`, `/legion/sessions`); and after
93
- the command exits nothing else resolves differently. The workspace is excluded because its jj
89
+ under `/legion`, which outlives a role's relaunch in its pod and is shared by the issue's roles —
90
+ whether through a variable the runtime sets for you there or under a path on that volume your
91
+ harness names itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under
92
+ `/legion/workspaces`) and the runtime's own directories (`/legion/repos`, `/legion/feed`); and
93
+ after the command exits nothing else resolves differently. The workspace is excluded because its jj
94
94
  working copy tracks every new file under jj's `snapshot.max-new-file-size` (1 MiB in a role
95
95
  container), so a root written there is added to the change every role shares, and a larger new
96
96
  file is left untracked, every jj command in the workspace then warning `Refused to snapshot some
@@ -122,7 +122,7 @@ What each shape leaves resolving:
122
122
  all. `uv sync` or `bun install` in your workspace materialises what that project already
123
123
  declares, into that project's own directory, which a process reaches by asking for that project
124
124
  and no other way — and the runtime puts `UV_PYTHON_INSTALL_DIR` and the workspace itself on the
125
- issue's volume exactly so a later pod reuses it.
125
+ issue's volume exactly so every role of the issue's pod reuses it.
126
126
  - An extraction prefix: whatever you leave resolving through it, which is yours to decide. Unpack
127
127
  Debian packages under `$HOME`, export `LD_LIBRARY_PATH` in the shell you go on working in, and
128
128
  you change what every later command in that shell — and every subagent that inherits the
@@ -147,8 +147,8 @@ toolchain — is where the first condition does the work. `uv run --with pyyaml=
147
147
  passes both: the wheel lands in `UV_CACHE_DIR`, under `wheels-v6/pypi/pyyaml/6.0.2-…`, and
148
148
  `UV_PYTHON_INSTALL_DIR` is never created, because the image's own `python3` served the run. The
149
149
  interpreter is the other run: a project whose `.python-version` names an interpreter uv must fetch
150
- puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where the issue's next pod finds it, while the
151
- cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
150
+ puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where every role of the issue's pod finds it, while
151
+ the cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
152
152
  repository's own Dispatch suite installs (`playwright install`, `packages/dispatch`'s
153
153
  `e2e:install`) — it passes as it stands, with nothing pointed anywhere: playwright-core resolves
154
154
  its browser registry under `XDG_CACHE_HOME`, which the runtime has already put on the issue's
@@ -164,11 +164,13 @@ here) at a directory under one of the paths above yourself, since an unset one l
164
164
  wherever the tool defaults, which is nowhere this runtime chose.
165
165
 
166
166
  Where it lands is what you pay. Under one of those variables it is on the issue's volume: it
167
- outlives the pod, and every role of the issue shares it — under every one of those variables but
168
- `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, "the one directory of the issue's
169
- scratch that is the role's own rather than the issue's" (`roleEnvironment`), because Oh My Pi
170
- names its browser broker's lock from the state root and an abstract socket name is one name across
171
- every container of the pod — and a later pod of the issue reuses it. Under `$HOME` —
167
+ outlives a role's relaunch in its pod, and every role of the issue shares it — under every one of
168
+ those variables but `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, "the one directory
169
+ of the issue's scratch that is the role's own rather than the issue's" (`roleEnvironment`),
170
+ because Oh My Pi names its browser broker's lock from the state root and an abstract socket name
171
+ is one name across every container of the pod — though not the pod itself: the issue's disk is
172
+ the pod's own and is deleted with it, so a later pod of the issue starts without it. Under
173
+ `$HOME` —
172
174
  `/home/legion`, which no volume is mounted on — it is the container's own filesystem: one copy per
173
175
  role, per pod, gone when the role relaunches, and charged to the container's ephemeral-storage
174
176
  limit, which is per role and smaller than the volume. Moving exactly these writes off that
@@ -178,7 +180,7 @@ under `$HOME` fails the first condition and is outside the sanctioned form, howe
178
180
  changes. The three artefacts above are the whole of that line's ground. Disk is not: every cache is
179
181
  already relocated onto the issue's volume, which has the room for them (`docs/kubernetes.md`, "The
180
182
  tools a role's own work needs", carries the figure), and a sanctioned install is paid once per
181
- issue, on the issue's own disk, by the issue's roles together.
183
+ issue pod, on the issue's own disk, by the issue's roles together.
182
184
 
183
185
  **The worked case, and the one a role actually meets: the Dispatch e2e stack.** A change to
184
186
  `packages/dispatch` is proved by driving its browser suite, which needs a Postgres with pgvector;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "10.29.1",
3
+ "version": "10.30.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "legion": {
15
- "daemonApiVersion": 36
15
+ "daemonApiVersion": 37
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",