@sjawhar/opencode-legion-envoy 8.20.13 → 8.22.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/dispatch.js CHANGED
@@ -13930,6 +13930,7 @@ function dispatchToolSchema(spec, z2, opts) {
13930
13930
  return spec.validation === undefined ? z2.object(shape, schemaOptions) : z2.refineObject(shape, spec.validation.check, spec.validation.refusal ?? spec.validation.message, schemaOptions);
13931
13931
  }
13932
13932
  var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference. An external reference addresses an existing Dispatch issue, including one linked to that GitHub pull request; only dispatch issue with --external creates a native issue.";
13933
+ var BOARD_NEIGHBOUR_REFUSAL = "The server refuses a key that does not exist, one in another project, this issue itself, an empty value, and a pair the board does not hold in that order.";
13933
13934
  var OWNER_REFERENCE = "Exactly one of issue and project is required. An issue is a native KEY or external owner/repo#n reference; a project is a project key such as CORE and addresses an unlinked project document named by artifact.";
13934
13935
  var ASK_QUESTION_CONTRACT = "The question carries the problem the reader recognises and why it matters now, what constrains " + "the answer, and the recommendation with its reason. It asks how to solve the problem or which " + "outcome is wanted; never enumerate choices in the question.";
13935
13936
  var ASK_OPTIONS_CONTRACT = "Options carry the genuinely different approaches. Each option has a label, and its description " + "says what that approach costs.";
@@ -13969,7 +13970,7 @@ var commentValidation = {
13969
13970
  },
13970
13971
  message: `${commentOwner.message} turn requires reply_to_ask.`
13971
13972
  };
13972
- var ISSUE_UPDATE_FIELDS_RULE = "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, or repo.";
13973
+ var ISSUE_UPDATE_FIELDS_RULE = "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, repo, before, or after.";
13973
13974
  var ISSUE_UPDATE_DONE_RULE = "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts";
13974
13975
  var ISSUE_UPDATE_REASON_RULE = "reason goes only with status done";
13975
13976
  var ISSUE_UPDATE_RULE = `${ISSUE_UPDATE_FIELDS_RULE} ${ISSUE_UPDATE_DONE_RULE}; ${ISSUE_UPDATE_REASON_RULE}.`;
@@ -13983,7 +13984,7 @@ function issueUpdateProblem(value) {
13983
13984
  const status = typeof input.status === "string" ? `status ${input.status}` : "an update without status done";
13984
13985
  return `${ISSUE_UPDATE_REASON_RULE}: it is the note posted on the issue as it closes, and ${status} closes nothing. Drop reason, or set status done to close the issue.`;
13985
13986
  }
13986
- const changesAField = typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string";
13987
+ const changesAField = typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string" || typeof input.before === "string" || typeof input.after === "string";
13987
13988
  return changesAField ? undefined : ISSUE_UPDATE_FIELDS_RULE;
13988
13989
  }
13989
13990
  var messageValidation = {
@@ -14068,10 +14069,10 @@ var dispatchToolSpecs = [
14068
14069
  name: "dispatch_issue_update",
14069
14070
  example: {
14070
14071
  issue: "DSP-1",
14071
- status: "done",
14072
- reason: "Shipped in owner/repo#7; verified on the production dashboard."
14072
+ priority: 1,
14073
+ before: "DSP-2"
14073
14074
  },
14074
- description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, or set the repository it targets. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
14075
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, set the repository it targets, or move it on its project's " + "board. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only a board move " + "(before, after), components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
14075
14076
  arguments: (z2) => ({
14076
14077
  issue: z2.string().describe(ISSUE_REFERENCE),
14077
14078
  status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -14084,7 +14085,9 @@ var dispatchToolSpecs = [
14084
14085
  parent: z2.string().describe("Parent issue key in the same project; an empty string clears the parent.").optional(),
14085
14086
  blocked_by: z2.array(z2.string({ min: 1 }), { max: MAX_ISSUE_BLOCKERS }).describe("Replacement list of issue keys in the same project this issue waits on; [] clears it. Dispatch refuses a dependency cycle.").optional(),
14086
14087
  components: componentsArgument(z2).optional(),
14087
- repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional()
14088
+ repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional(),
14089
+ before: z2.string().describe(`Move the issue above this issue on its project's board: an issue key in the same project. With after, the issue lands between the two. ${BOARD_NEIGHBOUR_REFUSAL}`).optional(),
14090
+ after: z2.string().describe(`Move the issue below this issue on its project's board: an issue key in the same project. With before, the issue lands between the two. ${BOARD_NEIGHBOUR_REFUSAL}`).optional()
14088
14091
  }),
14089
14092
  validation: {
14090
14093
  check: (value) => issueUpdateProblem(value) === undefined,
@@ -17805,6 +17808,8 @@ async function executeDispatchTool(input) {
17805
17808
  const parent = optionalString(args, "parent");
17806
17809
  const components = optionalComponents(args, "components");
17807
17810
  const repo2 = optionalString(args, "repo");
17811
+ const rankBefore = optionalString(args, "before");
17812
+ const rankAfter = optionalString(args, "after");
17808
17813
  const priority = optionalPriority(args, "priority");
17809
17814
  const labels = Array.isArray(args.labels) ? args.labels : undefined;
17810
17815
  const blockedBy = Array.isArray(args.blocked_by) ? args.blocked_by : undefined;
@@ -17833,6 +17838,10 @@ async function executeDispatchTool(input) {
17833
17838
  const linked = before.external_links.map((link) => link.url);
17834
17839
  const newLinks = requestedLinks?.filter((url2) => !linked.includes(url2)) ?? [];
17835
17840
  const sentLinks = requestedLinks === undefined ? undefined : requestedLinks.length === 0 ? [] : [...before.external_links, ...newLinks.map((url2) => ({ url: url2 }))];
17841
+ const rank = rankBefore === undefined && rankAfter === undefined ? undefined : {
17842
+ ...rankBefore === undefined ? {} : { before: rankBefore },
17843
+ ...rankAfter === undefined ? {} : { after: rankAfter }
17844
+ };
17836
17845
  let after;
17837
17846
  try {
17838
17847
  after = await client.updateIssue(issueKey, {
@@ -17846,6 +17855,7 @@ async function executeDispatchTool(input) {
17846
17855
  ...components === undefined ? {} : { components },
17847
17856
  ...repo2 === undefined ? {} : { repo: repo2 === "" ? null : repo2 },
17848
17857
  ...sentLinks === undefined ? {} : { external_links: sentLinks },
17858
+ ...rank === undefined ? {} : { rank },
17849
17859
  actor
17850
17860
  });
17851
17861
  } catch (error48) {
@@ -17867,6 +17877,12 @@ async function executeDispatchTool(input) {
17867
17877
  ...title === undefined ? [] : [`title "${after.title}"`],
17868
17878
  ...labels === undefined ? [] : [after.labels.length === 0 ? "labels cleared" : `labels ${after.labels.join(", ")}`],
17869
17879
  ...priority === undefined ? [] : [after.priority === null ? "priority cleared" : `priority -> P${after.priority}`],
17880
+ ...rank === undefined ? [] : [
17881
+ `rank -> ${[
17882
+ ...rankAfter === undefined ? [] : [`after ${rankAfter}`],
17883
+ ...rankBefore === undefined ? [] : [`before ${rankBefore}`]
17884
+ ].join(", ")}`
17885
+ ],
17870
17886
  ...requestedLinks === undefined ? [] : [
17871
17887
  requestedLinks.length === 0 ? "external links cleared" : newLinks.length === 0 ? `already linked ${requestedLinks.join(", ")} ${linkCount}` : `linked ${newLinks.join(", ")} ${linkCount}`
17872
17888
  ],
@@ -13787,6 +13787,7 @@ var SESSION_ID_PATTERN = "[^/?#\\s\\p{Z}\\p{Cc}<>\"'`\\[\\]|]+";
13787
13787
  var sessionIdOnly = new RegExp(`^${SESSION_ID_PATTERN}$`, "u");
13788
13788
  // ../contracts/src/dispatch-tools.ts
13789
13789
  var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference. An external reference addresses an existing Dispatch issue, including one linked to that GitHub pull request; only dispatch issue with --external creates a native issue.";
13790
+ var BOARD_NEIGHBOUR_REFUSAL = "The server refuses a key that does not exist, one in another project, this issue itself, an empty value, and a pair the board does not hold in that order.";
13790
13791
  var OWNER_REFERENCE = "Exactly one of issue and project is required. An issue is a native KEY or external owner/repo#n reference; a project is a project key such as CORE and addresses an unlinked project document named by artifact.";
13791
13792
  var ASK_QUESTION_CONTRACT = "The question carries the problem the reader recognises and why it matters now, what constrains " + "the answer, and the recommendation with its reason. It asks how to solve the problem or which " + "outcome is wanted; never enumerate choices in the question.";
13792
13793
  var ASK_OPTIONS_CONTRACT = "Options carry the genuinely different approaches. Each option has a label, and its description " + "says what that approach costs.";
@@ -13826,7 +13827,7 @@ var commentValidation = {
13826
13827
  },
13827
13828
  message: `${commentOwner.message} turn requires reply_to_ask.`
13828
13829
  };
13829
- var ISSUE_UPDATE_FIELDS_RULE = "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, or repo.";
13830
+ var ISSUE_UPDATE_FIELDS_RULE = "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, repo, before, or after.";
13830
13831
  var ISSUE_UPDATE_DONE_RULE = "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts";
13831
13832
  var ISSUE_UPDATE_REASON_RULE = "reason goes only with status done";
13832
13833
  var ISSUE_UPDATE_RULE = `${ISSUE_UPDATE_FIELDS_RULE} ${ISSUE_UPDATE_DONE_RULE}; ${ISSUE_UPDATE_REASON_RULE}.`;
@@ -13840,7 +13841,7 @@ function issueUpdateProblem(value) {
13840
13841
  const status = typeof input.status === "string" ? `status ${input.status}` : "an update without status done";
13841
13842
  return `${ISSUE_UPDATE_REASON_RULE}: it is the note posted on the issue as it closes, and ${status} closes nothing. Drop reason, or set status done to close the issue.`;
13842
13843
  }
13843
- const changesAField = typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string";
13844
+ const changesAField = typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string" || typeof input.before === "string" || typeof input.after === "string";
13844
13845
  return changesAField ? undefined : ISSUE_UPDATE_FIELDS_RULE;
13845
13846
  }
13846
13847
  var messageValidation = {
@@ -13925,10 +13926,10 @@ var dispatchToolSpecs = [
13925
13926
  name: "dispatch_issue_update",
13926
13927
  example: {
13927
13928
  issue: "DSP-1",
13928
- status: "done",
13929
- reason: "Shipped in owner/repo#7; verified on the production dashboard."
13929
+ priority: 1,
13930
+ before: "DSP-2"
13930
13931
  },
13931
- description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, or set the repository it targets. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13932
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, set the repository it targets, or move it on its project's " + "board. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only a board move " + "(before, after), components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13932
13933
  arguments: (z2) => ({
13933
13934
  issue: z2.string().describe(ISSUE_REFERENCE),
13934
13935
  status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -13941,7 +13942,9 @@ var dispatchToolSpecs = [
13941
13942
  parent: z2.string().describe("Parent issue key in the same project; an empty string clears the parent.").optional(),
13942
13943
  blocked_by: z2.array(z2.string({ min: 1 }), { max: MAX_ISSUE_BLOCKERS }).describe("Replacement list of issue keys in the same project this issue waits on; [] clears it. Dispatch refuses a dependency cycle.").optional(),
13943
13944
  components: componentsArgument(z2).optional(),
13944
- repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional()
13945
+ repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional(),
13946
+ before: z2.string().describe(`Move the issue above this issue on its project's board: an issue key in the same project. With after, the issue lands between the two. ${BOARD_NEIGHBOUR_REFUSAL}`).optional(),
13947
+ after: z2.string().describe(`Move the issue below this issue on its project's board: an issue key in the same project. With before, the issue lands between the two. ${BOARD_NEIGHBOUR_REFUSAL}`).optional()
13945
13948
  }),
13946
13949
  validation: {
13947
13950
  check: (value) => issueUpdateProblem(value) === undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "8.20.13",
3
+ "version": "8.22.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -9,7 +9,8 @@ Read this to choose, claim or release an issue, or to handle `409 ISSUE_CLAIMED`
9
9
  - Hold at most three issues in flight (`in_progress`, `testing`, `needs_review`, `retro`), of any kind, per agent. A child counts on its own. Priorities decide only what you pull next. Past three: push unfinished work, say where in one comment, move it to `backlog` and clear its route.
10
10
  - An in-flight issue with no owner goes back to `backlog`. No owner means no claim or route held by a live session, no Dispatch activity in a day, and no PR moving on GitHub. The order keeper sweeps those.
11
11
  - Never write the status of an issue with the `legion` label or under one. A status you write on a running Legion root is set back and its architect is told. Only a person in the dashboard, the controller's `set_status`, or the operator stops that tree. On a child, a status out of the flow parks it and stops its workers.
12
- - Set priority yourself. Never reorder the board: one agent keeps the order. When the top looks wrong, or a priority's next step is not a ready issue, publish to `notifications.role.backlog-order`.
12
+ - Set priority yourself. Never reorder the board: one agent keeps each project's order, and in a Legion deployment that agent is the controller, at `notifications.role.legion-<project>-controller`. When the top looks wrong, or a priority's next step is not a ready issue, publish it there, and say it on the issue where no Legion controller runs.
13
+ - The keeper moves a card with `dispatch issue-update --issue <KEY> --before <OTHER>` or `--after <OTHER>`.
13
14
 
14
15
  ## Claim the issue before you work it
15
16
 
@@ -36,13 +37,13 @@ dispatch claim --issue LEGION-234 --release # I have stopped; it is free
36
37
  ## Priority is yours to set
37
38
 
38
39
  - `0` (P0, highest) to `3` (P3), `null` clears. Set it on creation and on grooming passes over issues with none. Say what you set and why. The human overrides from the dashboard.
39
- - A closed issue takes only `rank`, `components`, a reopening status, and an external-links write that adds no link. Reopen first, then set priority, in two commands (`409 ISSUE_CLOSED`).
40
+ - A closed issue takes only a board move (`--before` / `--after`), `components`, a reopening status, and an external-links write that adds no link. Reopen first, then set priority, in two commands (`409 ISSUE_CLOSED`).
40
41
  - Labels are optional. Dispatch trims them, keeps their case, and drops case-insensitive duplicates.
41
42
 
42
- ## Board rank, priority, and assignee on a read
43
+ ## Board rank, priority, and assignee
43
44
 
44
45
  - Reads carry `rank`, nullable priority, and `assignee`: a lowercase email, or `null`. `dispatch read` prints `Assignee: unassigned` for `null`.
45
- - Reorder with `PATCH /api/v1/issues/{key}` and `{"rank": {"before": "<key>", "after": "<key>"}}` (either optional, same project). A bearer also sends `"actor": {"kind": "session", "id": "<your session id>"}`, or gets `ACTOR_KIND`.
46
+ - Move a card with `dispatch issue-update --issue <KEY> --before <OTHER>`, which puts it above that issue, `--after <OTHER>`, which puts it below, or both together, which land it between them. The neighbour is an issue of the same project and never the issue itself, and the server refuses a pair the board does not hold in that order. The move rides along with any other change in the same call, and a closed issue takes it.
46
47
 
47
48
  ## Reading a project's backlog
48
49
 
@@ -463,7 +463,9 @@ addressing` line names: a child that looks independent of the tree (the controll
463
463
  whether to re-file it as a root of its own, and does so only on their yes), capacity, and a
464
464
  cross-tree conflict. A problem outside the tree is a `dispatch ask` you open, never an issue you
465
465
  file (`skill://legion-worker/references/findings.md`). Handle everything else in the
466
- tree. A product, scope, or design decision that needs the human, yours or one a worker escalated,
466
+ tree. A tool a worker cannot get is yours to schedule (`skill://legion-worker/references/tools.md`):
467
+ the image change is a pull request against this repository, never something a worker conjures.
468
+ A product, scope, or design decision that needs the human, yours or one a worker escalated,
467
469
  is a decision block you write (section 1 says what one does to the root spec's gate). A standalone
468
470
  human to-do may use `dispatch ask`; workers may reach the human directly with it the same way. Do not
469
471
  create a wait loop for any wake source.
@@ -1,9 +1,9 @@
1
1
  # Backlog stewardship
2
2
 
3
3
  The controller keeps the Dispatch backlog's facts aligned with the deployment's current weekly
4
- plan. The plan is the source of the work order and its one-owner map; Dispatch priority and board
5
- rank are the executable form of that order. The deployment instructions name the weekly-planning
6
- issue and its `plan-week-*` artifact.
4
+ plan. The plan is the source of the work order and its one-owner map; Dispatch board rank is the
5
+ executable form of that order. The deployment instructions name the weekly-planning issue and its
6
+ `plan-week-*` artifact.
7
7
 
8
8
  ## Controller routing address
9
9
 
@@ -18,12 +18,13 @@ Run this pass on the first turn of every UTC day and after every ruling from a p
18
18
 
19
19
  1. Read the weekly-planning issue and select its newest `plan-week-*` artifact by its latest
20
20
  `artifact.version` event. Read that artifact before changing any issue.
21
- 2. For every current plan line in this deployment's project, make Dispatch's sort order exactly
22
- match the line order: set the coarse priority and board rank together so the admission rule
23
- (highest priority, then board rank) produces the plan's order. Keep each line's own stated
24
- sequence. Do not invent a second order from a title, a stale route, or an instruction.
25
- Reorder through Dispatch's board-rank API described in
26
- `skill://dispatch/references/issues.md#board-rank-priority-and-assignee-on-a-read`.
21
+ 2. For every current plan line in this deployment's project, make the project's board order match
22
+ the line order exactly: Legion admits waiting roots by board rank alone — unlike the ready order
23
+ an agent takes its own next issue by, which is priority, then board rank — so board rank is what
24
+ carries the plan's order. Set the coarse priority too, as the signal people and filters read; it
25
+ changes no order. Keep each line's own stated sequence. Do not invent a second order from a
26
+ title, a stale route, or an instruction. Reorder with
27
+ `dispatch issue-update --issue <KEY> --before <OTHER>` or `--after <OTHER>`.
27
28
  3. Reconcile the plan's one-owner map. For each listed owner, verify the named session is live.
28
29
  For unowned work, find a suitable live owner with `envoy_sessions` filtered by `title` or `dir`,
29
30
  route the issue to that exact session, and tell it what it owns. Routing hands the work to that
@@ -28,6 +28,7 @@ with the `read` tool: a read by filesystem path stops at 300 lines.
28
28
  | You review or approve, build the READY packet, or check the merge in production | `skill://legion-worker/references/merge-gate.md` |
29
29
  | The issue renames a repository, package, or URL across the codebase | `skill://legion-worker/references/systematic-rename.md` |
30
30
  | The issue deletes code, a command, or documentation | `skill://legion-worker/references/cleanup-deletion.md` |
31
+ | Your phase needs a tool — a client, a linter, a CLI — that the image may not carry | `skill://legion-worker/references/tools.md` |
31
32
  | You find a problem outside your issue, or the production check fails | `skill://legion-worker/references/findings.md` |
32
33
 
33
34
  ## Identity, scope, and role
@@ -136,12 +137,11 @@ committed predecessor handoffs in lifecycle order, with the `read` tool from
136
137
  4. `review.json`
137
138
 
138
139
  Read only files that precede the assigned phase. Each was checked against its phase's rules when
139
- its phase completed (the completion gate below): fields the phase does not declare passed
140
- untouched and reach the next worker.
141
- Write the phase-specific fields the next phase and the architect need, consistent with what
142
- predecessor phases already wrote. The durable copy lives in
143
- `$LEGION_WORKSPACE/.legion/<issue>/<phase>.json`. If a committed handoff conflicts with memory or a prior
144
- transcript, the committed file wins: it is the copy that survived.
140
+ its phase completed (the completion gate below): fields the phase does not declare passed untouched
141
+ and reach the next worker. Write the phase-specific fields the next phase and the architect need,
142
+ consistent with what predecessor phases already wrote. The durable copy lives in
143
+ `$LEGION_WORKSPACE/.legion/<issue>/<phase>.json`. If a committed handoff conflicts with memory or a
144
+ prior transcript, the committed file wins: it is the copy that survived.
145
145
 
146
146
  If your task, prompt, or a message since your last turn begins `Your workspace was recreated…`, read
147
147
  your phase's committed handoff and any `.legion/<issue>/workspace-recovered.json`, and reconcile
@@ -0,0 +1,407 @@
1
+ # Getting the tool your work needs
2
+
3
+ Part of `skill://legion-worker`. Read it the moment your phase needs a tool the worker image may
4
+ not carry — a database client, a linter, a one-off CLI — before you install, download, or vendor
5
+ anything. Every path it cites is in sjawhar/legion.
6
+
7
+ ## The five clients the image already carries
8
+
9
+ Check this list first: the worker image carries these five from the change that added them onward,
10
+ and they are on a role's `PATH` once its deployment pins a digest at or after that change — a role
11
+ still on an older digest finds none of them. Run `command -v psql redis-cli sqlite3 nc dig` to see
12
+ which you have; those need no runner.
13
+
14
+ - `psql` — the Dispatch browser suite (`bun run e2e`) resets its database for every spec through
15
+ `packages/dispatch/e2e/psql.ts`, which `seed.ts` calls, and the local stack (`bun run
16
+ dispatch:local`, `packages/dispatch/e2e/local-instance.ts`) refuses to start without it on
17
+ `PATH` — so a role whose change owes that suite can run it.
18
+ - `redis-cli` — no repository of the project speaks Redis today; it is carried anyway, at about a
19
+ megabyte, because a second image roll costs more than a megabyte.
20
+ - `sqlite3` — a repository's own SQLite fixture or database has no other way to open from a shell,
21
+ and Oh My Pi's own profile databases (`~/.omp/profiles/legion/agent/agent.db`, `models.db`,
22
+ `history.db`) are SQLite too. Your session is not one of them: it is `results.jsonl`,
23
+ `state.json` and `title` under `XDG_STATE_HOME`.
24
+ - `nc` — the reachability check you would otherwise write as an inline Python loop. The name comes
25
+ from the alternatives system, so ask for `nc`, never `nc.openbsd`.
26
+ - `dig` — how a role whose connection to a service name fails tells "the name does not resolve"
27
+ from "the packets are dropped".
28
+
29
+ ## Why a sixth is an image change, not an install
30
+
31
+ Your container is an unprivileged user with no writable system prefix: `/usr/local/bin` and `/opt`
32
+ are read-only, `/home` is read-only too, and `apt-get` cannot take dpkg's lock even to download.
33
+ `$HOME` — `/home/legion`, the one writable directory under that read-only `/home` — is yours to
34
+ write.
35
+
36
+ ```text
37
+ $ apt-get install -d -y postgresql-client
38
+ E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)
39
+ ```
40
+
41
+ So that writable `$HOME`, and the issue's disk, are where an install for yourself would go, and on
42
+ the mechanics the answer is not a flat no: `redis-cli`, `sqlite3` and `dig` did run from an
43
+ extraction prefix under `$HOME` with `LD_LIBRARY_PATH` set, and `psql` did not — Debian's
44
+ `/usr/bin/psql` is a Perl wrapper that loads `PgCommon.pm` through Perl's own search path, which no
45
+ extraction prefix is on. What rules such an attempt out is not the unpacking: it is the next
46
+ section's test ("The test: where it lands, and what it leaves resolving"), and an extraction under
47
+ `$HOME` fails that test's first condition whatever you scope it to, because `/home/legion` is the
48
+ container's own filesystem rather than the issue's volume — the placement `roleEnvironment`
49
+ (`packages/daemon/internal/runtime/sandbox/manifest.go`) exists to move writes off. The sanctioned
50
+ extraction is the harness's: onto the issue's volume, and exported around its own commands and no
51
+ further (the Dispatch e2e stack, below, is the worked case). For everything the five do not cover,
52
+ the one documented step is a pinned run through one of the three package runners the image already
53
+ carries — plus `uv run --with` when what you need is a library rather than a command — and a tool
54
+ no runner reaches is an image change ("When no runner reaches it", below).
55
+
56
+ ## What the withheld capability is, and what it is not
57
+
58
+ The withheld row reads `installs or configures its own dependencies in the pod: Legion owns its
59
+ dependencies` (`packages/daemon/internal/capabilities/capabilities.go`, ruling
60
+ dispatch://LEGION-200). Read as a sentence on its own, that wording would also forbid `uvx`,
61
+ `bunx --package` and `go run`, each of which fetches a package and writes a cache. It does not, and
62
+ three artefacts in this repository are what draw the line — not a claim about what the ruling meant:
63
+
64
+ - **What the image ships for a role to use.** `packages/daemon/docker/worker.Dockerfile` installs
65
+ `uv` and `uvx`, `bun` with its `bunx` link, and the Go toolchain, and the `toolchain` capability
66
+ row (`packages/daemon/internal/capabilities/capabilities.go`) grants them by name. A runner put
67
+ in the image deliberately and granted by a capability row is not a route around a withheld one.
68
+ - **What the pod configures for those runners to write.** A role container's environment relocates
69
+ `UV_CACHE_DIR` and `UV_PYTHON_INSTALL_DIR` (`podkind.go`) and `BUN_INSTALL_CACHE_DIR`, `GOCACHE`
70
+ and `GOMODCACHE` (`roleEnvironment`, `packages/daemon/internal/runtime/sandbox/manifest.go`)
71
+ onto the issue's volume, shared by the issue's roles. Legion provisions the caches a pinned run
72
+ fills; filling them is what they are for.
73
+ - **What Legion itself does at launch.** `installBlock`
74
+ (`packages/daemon/internal/runtime/sandbox/plugins.go`) renders a `bun add` under
75
+ `/usr/bin/flock` into that shared bun install cache on every installing launch. Fetching a pinned
76
+ package at run time is something Legion does, not something it withholds.
77
+
78
+ So the withheld row is about a dependency **the pod carries**: something that outlives the command
79
+ that created it, and that a later launch or another role's process then resolves *without asking
80
+ for it*.
81
+
82
+ ### The test: where it lands, and what it leaves resolving
83
+
84
+ An install of your own — an interpreter, a browser, a second toolchain, an extraction prefix — is
85
+ on the sanctioned side when both of these hold: what it writes lands on the issue's own volume,
86
+ under `/legion`, which outlives the pod and is shared by the issue's roles — whether through a
87
+ variable the runtime sets for you there or under a path on that volume your harness names
88
+ itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under `/legion/workspaces`) and
89
+ the runtime's own directories (`/legion/repos`, `/legion/feed`, `/legion/sessions`); and after
90
+ the command exits nothing else resolves differently. The workspace is excluded because its jj
91
+ working copy tracks every new file, so a root written there is added to the change every role
92
+ shares; the one write into it that is sanctioned is a repository's own dependency install into
93
+ its ignored directories, below. The variables the runtime sets on the volume, where a tool's own
94
+ cache already goes, are
95
+ `XDG_CACHE_HOME` (`/legion/scratch/cache`), `XDG_DATA_HOME`, `XDG_STATE_HOME`, `TMPDIR`, `GOCACHE`,
96
+ `GOMODCACHE`, `BUN_INSTALL_CACHE_DIR`, `UV_CACHE_DIR` (`/legion/uv/cache`) and
97
+ `UV_PYTHON_INSTALL_DIR` (`/legion/uv/python/<issue>`). (`XDG_CONFIG_HOME` is set too and is not
98
+ one of them: it is the pod's shared in-memory volume.) `$HOME` and `/tmp` are not on the volume:
99
+ both are the container's own filesystem.
100
+
101
+ It is the withheld capability when either condition fails — anything that writes into a
102
+ system prefix, and anything that leaves a toolchain, shim or binary which a later launch or
103
+ another role's process will resolve without asking for it. Between two installs that both land in
104
+ the right place, the test is resolution, not disk, and it reads as scope: a second toolchain
105
+ exported for the commands of your own run — by absolute path, or on a `PATH` your harness's own
106
+ script sets around its own invocations — is sanctioned; the same toolchain left where a later
107
+ launch, another role or a subagent picks it up without naming it is operator setup, whoever typed
108
+ the command.
109
+
110
+ What each shape leaves resolving:
111
+
112
+ - A pinned runner run: nothing. `uvx --from httpie@3.2.4 http --version` leaves a cache entry
113
+ addressed by that coordinate and that version, which the next command reaches only by writing the
114
+ same pin; no `PATH`, no interpreter and no library search of any other process changes. That is
115
+ the sanctioned side, and it is the side the forms below are on.
116
+ - A repository's own dependency set: nothing either, which is why the caches are provisioned at
117
+ all. `uv sync` or `bun install` in your workspace materialises what that project already
118
+ declares, into that project's own directory, which a process reaches by asking for that project
119
+ and no other way — and the runtime puts `UV_PYTHON_INSTALL_DIR` and the workspace itself on the
120
+ issue's volume exactly so a later pod reuses it.
121
+ - An extraction prefix: whatever you leave resolving through it, which is yours to decide. Unpack
122
+ Debian packages under `$HOME`, export `LD_LIBRARY_PATH` in the shell you go on working in, and
123
+ you change what every later command in that shell — and every subagent that inherits the
124
+ environment — resolves `psql` or its libraries to, asked for or not, on a prefix nothing records,
125
+ pins or rebuilds: that fails both conditions, and it is the withheld capability. The harness
126
+ below is the shape that passes. The opposite shape is in the tree too, and superseded: telling
127
+ every subagent to prepend a directory to `PATH` because a subagent's shell starts without it
128
+ hands over an environment to processes that never named it. That is the difference between
129
+ standing a harness up and getting yourself a CLI.
130
+
131
+ The test is about what a command leaves for other processes, never about getting the same bytes
132
+ twice — because a pinned line does not promise the same bytes. What a pin binds is the named
133
+ distribution at that version; its dependency closure is resolved when the command runs. With
134
+ `UV_CACHE_DIR` emptied, `uvx -v --from httpie@3.2.4 http --version` logs `Selecting:
135
+ requests==2.34.2 [compatible]` and resolves `urllib3` and `certifi` from ranges, where the warm run
136
+ prints `3.2.4` at once off the cache that first run filled. When your work needs the closure pinned
137
+ too — a comparison that has to hold across days or across pods — that is a project's own lockfile
138
+ with `uv run` or `bun install` in a workspace, not a one-off runner line.
139
+
140
+ A pinned run whose tool then installs something of its own — an interpreter, a browser, a second
141
+ toolchain — is where the first condition does the work. `uv run --with pyyaml==6.0.2 --no-project`
142
+ passes both: the wheel lands in `UV_CACHE_DIR`, under `wheels-v6/pypi/pyyaml/6.0.2-…`, and
143
+ `UV_PYTHON_INSTALL_DIR` is never created, because the image's own `python3` served the run. The
144
+ interpreter is the other run: a project whose `.python-version` names an interpreter uv must fetch
145
+ puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where the issue's next pod finds it, while the
146
+ cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
147
+ repository's own Dispatch suite installs (`playwright install`, `packages/dispatch`'s
148
+ `e2e:install`) — it passes as it stands, with nothing pointed anywhere: playwright-core resolves
149
+ its browser registry under `XDG_CACHE_HOME`, which the runtime has already put on the issue's
150
+ volume, so the issue's roles share one registry under the exclusive install lock that
151
+ `docs/kubernetes.md` writes down in "Anatomy of a Sandbox pod", the paragraph on sharing the cache
152
+ home — worth reading before an install, for the two things that follow from sharing. Pointing
153
+ `PLAYWRIGHT_BROWSERS_PATH` elsewhere is what costs you: that shared registry, and the lock that
154
+ makes a second role installing at the same time wait rather than race. The condition is for a tool
155
+ whose cache the runtime does not already place — Puppeteer is one, resolving its browser cache to
156
+ `join(homedir(), '.cache', 'puppeteer')` and so to the container's own filesystem whatever
157
+ `XDG_CACHE_HOME` says. For that one, point the tool's own cache variable (`PUPPETEER_CACHE_DIR`
158
+ here) at a directory under one of the paths above yourself, since an unset one leaves the download
159
+ wherever the tool defaults, which is nowhere this runtime chose.
160
+
161
+ Where it lands is what you pay. Under one of those variables it is on the issue's volume: it
162
+ outlives the pod, and every role of the issue shares it — under every one of those variables but
163
+ `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, "the one directory of the issue's
164
+ scratch that is the role's own rather than the issue's" (`roleEnvironment`), because Oh My Pi
165
+ names its browser broker's lock from the state root and an abstract socket name is one name across
166
+ every container of the pod — and a later pod of the issue reuses it. Under `$HOME` —
167
+ `/home/legion`, which no volume is mounted on — it is the container's own filesystem: one copy per
168
+ role, per pod, gone when the role relaunches, and charged to the container's ephemeral-storage
169
+ limit, which is per role and smaller than the volume. Moving exactly these writes off that
170
+ filesystem is what `roleEnvironment` (`packages/daemon/internal/runtime/sandbox/manifest.go`)
171
+ exists for, after GKE Autopilot evicted issue pods that charged their caches to it — so an install
172
+ under `$HOME` fails the first condition and is outside the sanctioned form, however little else it
173
+ changes. The three artefacts above are the whole of that line's ground. Disk is not: every cache is
174
+ already relocated onto the issue's volume, which has the room for them (`docs/kubernetes.md`, "The
175
+ tools a role's own work needs", carries the figure), and a sanctioned install is paid once per
176
+ issue, on the issue's own disk, by the issue's roles together.
177
+
178
+ **The worked case, and the one a role actually meets: the Dispatch e2e stack.** A change to
179
+ `packages/dispatch` is proved by driving its browser suite, which needs a Postgres with pgvector;
180
+ the image carries a `psql` and no server and no pgvector, and no runner reaches either. The way
181
+ that stack is stood up here is Debian packages unpacked with `dpkg-deb -x` into one root on the
182
+ issue's volume — the five packages and the start sequence are in
183
+ `docs/solutions/testing/the-real-dispatch-stack-runs-inside-a-legion-pod-with-no-postgres-no-docker-and-no-root-INFRA-42.md`,
184
+ and
185
+ `docs/solutions/testing/a-browser-harness-in-an-issue-pod-lives-on-the-issue-volume-INFRA-43.md`
186
+ puts it on the volume, moves the caches with it, and writes it as one idempotent script the next
187
+ role re-runs. That passes both conditions, and the second is the one to get right: export the
188
+ root's `PATH` and `LD_LIBRARY_PATH` around the harness's own commands — `initdb`, `pg_ctl`, the
189
+ server, the Playwright run — rather than into the shell you go on working in. Handing the harness
190
+ to the next role is the same test passed again, and
191
+ `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`
192
+ is the illustration: it leaves the server running and records the `LD_LIBRARY_PATH`, `PATH` and
193
+ `DATABASE_URL` in its handoff's `trickyParts`, so what crosses to the next role is a note that
194
+ role sets deliberately rather than an environment it inherits. The suite shells out to `psql`,
195
+ and `psql` being in the image is what makes that harness work now without your extracting a
196
+ client for yourself at all.
197
+
198
+ ## The three pinned runner forms
199
+
200
+ - `uvx --from <package>@<version> <command>`, for a Python tool: the distribution you pin and the
201
+ command you run are named separately, because they are usually different names.
202
+ `uvx --from httpie@3.2.4 http --version` prints `3.2.4`. Where the project's own name *is* its
203
+ command's — checked once, never assumed — the short `uvx <name>@<version>` runs it:
204
+ `uvx ruff@0.14.0 --version` prints `ruff 0.14.0`, in 1.7 s with the uv cache empty and 0.15 s
205
+ warm. A version that does not exist fails closed, exit 1, naming it: `uvx ruff@99.99.99
206
+ --version` prints `error: No solution found when resolving tool dependencies`, with `cause:
207
+ Because there is no version of ruff==99.99.99 …`.
208
+ - `bunx --package <name>@<version> <binary>`, for an npm tool, naming the package and the binary
209
+ separately because they differ. `bunx --package @biomejs/biome@2.6.3 biome --version` prints
210
+ `Version: 2.6.3`, in 0.3 s with bun's cache warm; `bunx --package @biomejs/biome@99.99.99 biome
211
+ --version` fails closed, exit 1, with `error: No version matching "99.99.99" found for specifier
212
+ "@biomejs/biome" (but package exists)`.
213
+ - `go run <module>@<version>`, for a Go tool. `go run github.com/nats-io/natscli/nats@v0.1.5
214
+ --version` prints `v0.1.5`, in 7.2 s with the module cache warm and 103 s on a cold module cache
215
+ — budget for the cold one the first time an issue runs a Go tool. `go run
216
+ github.com/nats-io/natscli/nats@v99.99.99 --version` fails closed, exit 1, with
217
+ `go: github.com/nats-io/natscli/nats@v99.99.99: invalid version: unknown revision nats/v99.99.99`.
218
+
219
+ ## When you need a library, not a command
220
+
221
+ The three forms above run somebody else's command. When what you need is a package available to
222
+ code of your own — PyYAML to read a file, not a CLI — the form is `uv run --with
223
+ <package>==<version> --no-project` ahead of the program you are running:
224
+
225
+ ```text
226
+ $ uv run --with pyyaml==6.0.2 --no-project python -c "import yaml; print(yaml.__version__)"
227
+ Installed 1 package in 12ms
228
+ 6.0.2
229
+ ```
230
+
231
+ That whole run is 0.597 s wall, the install included. `--no-project` keeps it off whatever
232
+ `pyproject.toml` happens to sit above your working directory, so it behaves the same wherever you
233
+ type it, and `--with` takes a requirement specifier (`==<version>`), not a runner coordinate
234
+ (`@<version>`). It is on the sanctioned side for the same reason a `uvx` run is: it fills
235
+ `UV_CACHE_DIR` and leaves nothing another process resolves. Reaching for a command runner instead
236
+ is the mistake the next section's `uvx pyyaml@6.0.2 --version` shows — uv installs the project and
237
+ then finds no executable, because a library has none to find.
238
+
239
+ ## Always write the exact coordinate and the exact version
240
+
241
+ A pin has two halves, and a runner resolves what you asked for only when both are right:
242
+
243
+ 1. **The coordinate** — the registry name the project itself publishes, scope and all:
244
+ `@biomejs/biome`, not `biome`. The command's name is no evidence of the package's. Take the
245
+ coordinate from the project's own published installation instructions — its documentation site
246
+ or its repository's README, reached independently of the registry rather than through the
247
+ registry entry of a name you guessed. That is the step, and it is the only one that establishes
248
+ identity. Do it once, before the tool's first use.
249
+ 2. **The version** on that coordinate.
250
+
251
+ Everything a registry can tell you afterwards is corroboration, and it is worth knowing which kind
252
+ each piece is.
253
+
254
+ **Fields the publisher writes.** `npm view <name> version description repository.url homepage`
255
+ reads what the publisher put in its own `package.json`. npm verifies none of it: a squatter closes
256
+ this check by copying two strings, and a legitimate package that sets no `homepage` reads like a
257
+ squat. Evidence, never proof.
258
+
259
+ **A check whose evidence the subject controls is not a check.** Carry that past npm: every line
260
+ below the next prompt was written by the party whose identity is in question.
261
+
262
+ ```text
263
+ $ npm view biome version description repository.url homepage
264
+ version = '0.3.3'
265
+ description = 'A simple way to manage environment variables on a per-project basis'
266
+ $ npm view @biomejs/biome version description repository.url homepage
267
+ version = '2.6.3'
268
+ description = 'Biome is a toolchain for the web: formatter, linter and more'
269
+ repository.url = 'git+https://github.com/biomejs/biome.git'
270
+ homepage = 'https://biomejs.dev'
271
+ ```
272
+
273
+ Those two answers are *consistent with* `@biomejs/biome` being the project and `biome` being
274
+ someone else's package. What settles it is biomejs.dev telling you to install `@biomejs/biome` —
275
+ the site you reached without npm's help.
276
+
277
+ **What a registry can attest.** npm verifies a provenance attestation when it is published, so it
278
+ is the one part of an entry a publisher cannot forge by writing a string. What it proves is *build
279
+ origin*: the attestation binds the artifact to the repository and workflow that built it. Presence
280
+ is not identity — a squat published from its own public Actions workflow carries a genuine
281
+ attestation of its own repository — so the check is to read whose build it names, and compare that
282
+ with the repository the project's own documentation gave you:
283
+
284
+ ```text
285
+ $ npm view @biomejs/biome dist.attestations.url
286
+ https://registry.npmjs.org/-/npm/v1/attestations/@biomejs%2fbiome@2.6.3
287
+ $ curl -s "$(npm view @biomejs/biome dist.attestations.url)" \
288
+ | jq -r '.attestations[] | select(.predicateType=="https://slsa.dev/provenance/v1") | .bundle.dsseEnvelope.payload' \
289
+ | base64 -d | jq -r '.predicate.buildDefinition.externalParameters.workflow.repository'
290
+ https://github.com/biomejs/biome
291
+ ```
292
+
293
+ That field is the SLSA v1 predicate's `buildDefinition.externalParameters.workflow`, which here
294
+ reads `{"ref": "refs/heads/main", "repository": "https://github.com/biomejs/biome", "path":
295
+ ".github/workflows/release.yml"}` — the repository biomejs.dev sent you to, building on its own
296
+ main branch. The comparison is what makes the attestation worth anything: on its own it says only
297
+ that *some* repository built these bytes. (`jq` in your bash tool is the harness's builtin, and a
298
+ script gets `/usr/bin/jq`; both print this.)
299
+
300
+ Its limit runs the other way: most packages publish no provenance at all — `npm view biome
301
+ dist.attestations` prints nothing — so an absent attestation is no evidence of a squat.
302
+
303
+ PyPI publishes attestations only for projects that release through a trusted publisher, and widely
304
+ used tools do without: for `ruff 0.14.0`, `https://pypi.org/pypi/ruff/0.14.0/json` carries no
305
+ `provenance` on any of its files and `https://pypi.org/integrity/ruff/0.14.0/<file>/provenance`
306
+ answers 404. For a Python tool, the project's own documentation is the whole of the check.
307
+
308
+ A Go module path is a domain-scoped URL, so the coordinate *is* the repository path, and you check
309
+ it by going to that path. `sum.golang.org` pins a version's bytes once they are published, which
310
+ stops the content changing under you later and says nothing about whether you picked the right
311
+ module path in the first place.
312
+
313
+ Pinning the version alone does not save you: it pins the squatter at a version, and obeys "write
314
+ the version" to the letter while doing it.
315
+
316
+ ```text
317
+ $ bunx biome --version
318
+ 0.3.3
319
+ $ bunx biome@0.3.3 --version
320
+ 0.3.3
321
+ $ bunx --package @biomejs/biome@2.6.3 biome --version
322
+ Version: 2.6.3
323
+ ```
324
+
325
+ Only the third runs the formatter, and what separates it from the second is the *name*, not the
326
+ version. No runner exempts you from the coordinate half; each one's syntax just makes a different
327
+ way of getting it wrong easy to type.
328
+
329
+ - `bunx <binary>@<version>` resolves the token as an npm package name, which is why
330
+ `--package <name>@<version> <binary>` exists: a bare `bunx biome@0.3.3` is a coordinate nobody
331
+ checked.
332
+ - `uvx <command>@<version>` does the same on PyPI, and carries it one step further: uv resolves
333
+ the token as the **project**, installs that project, and only then looks for a command of that
334
+ name.
335
+
336
+ ```text
337
+ $ uvx pyyaml@6.0.2 --version
338
+ Installed 1 package in 23ms
339
+ Package `pyyaml` does not provide any executables.
340
+ ```
341
+
342
+ Nothing there is about the version — `pyyaml` is a real project at a real version, and it is not
343
+ what you meant by typing a command's name. `uvx --help` is where the distinction lives:
344
+ `--from <FROM> Use the given package to provide the command`. Hence
345
+ `uvx --from shellcheck-py@0.10.0.1 shellcheck --version`, which prints `ShellCheck - shell
346
+ script analysis tool` and `version: 0.10.0`: the distribution and the command share no name at
347
+ all.
348
+ - `go run <module>@<version>` takes the coordinate as that domain-scoped URL, so a path you
349
+ invented usually fails to resolve rather than running someone else's code — but a module path
350
+ guessed from a binary's name is still a guess, and the checksum database will pin the wrong
351
+ module's bytes as faithfully as the right one's.
352
+
353
+ Nothing refuses an unpinned command: no guard, no lint and no check stops you typing a bare `bunx
354
+ biome`. What you are given is a correct form and a tool that is already there, not a gate — so both
355
+ halves are yours to type.
356
+
357
+ ## When no runner reaches it
358
+
359
+ A tool that is neither in the image nor reachable through one of the three runners is an image
360
+ change: a pull request against sjawhar/legion that carries the same name through every site that
361
+ spells it out, and those sites are not all held by a test.
362
+
363
+ Do not work from a list of those sites. This page has carried one twice and it was wrong both
364
+ times, so take them from the repository instead: grep for a client the image already carries,
365
+ under both of its names — the command and the Debian package that provides it are spelled
366
+ differently, and different sites spell each — and carry the new name through everything the two
367
+ find.
368
+
369
+ ```text
370
+ $ grep -rn redis-cli
371
+ $ grep -rn redis-tools
372
+ ```
373
+
374
+ Two kinds of hit want more than a substitution. The toolchain smoke step in
375
+ `packages/daemon/docker/worker.Dockerfile` runs each tool once, so a new one needs its own
376
+ invocation there (`nc` and `dig` take no `--version`); and the `THIRD_PARTY_NOTICES` intro string
377
+ in the same file is a licence file that hand-lists the Debian packages the image installs,
378
+ assembled into the image at `/usr/share/doc/legion/THIRD_PARTY_NOTICES`.
379
+
380
+ Four of the files the grep finds are in Go, and those four are the gated ones: the `toolchain`
381
+ name list in `packages/daemon/internal/capabilities/image.go` makes the probe require it; that
382
+ row's summary in `packages/daemon/internal/capabilities/capabilities.go` has to name it, a test
383
+ holding the summary to the list; the stub binaries in
384
+ `packages/daemon/internal/capabilities/capabilitiestest/capabilitiestest.go` have to include it, or
385
+ the capability and `probe-image` tests fail on an image that does not carry it; and
386
+ `packages/daemon/internal/capabilities/image_test.go` spells the expected detail string out again.
387
+
388
+ Miss one of those four and the suite is red, which is what makes them easy to find. Nothing holds
389
+ a Dockerfile comment, the notices string or the smoke step to anything today, so a change that
390
+ stops at the gated sites opens green and ships a licence file that does not name the package it
391
+ installs. That is a fact about this build rather than a property: dispatch://INFRA-255 generates
392
+ the package list from `dpkg-query` at build time and drops the hand-kept string, and once it lands
393
+ the notices half of this warning goes with it while the smoke step and the comments stay yours to
394
+ carry. It reaches a role only once a deployment pins the new image digest, as every Legion change
395
+ does — never within your own phase.
396
+
397
+ So when your work needs such a tool, say so to the architect that owns your issue (`envoy_publish`
398
+ to its role topic), with the tool, what your phase needs it for, and the runner forms you already
399
+ ruled out. What is not the step is improvising a CLI for your own convenience — a vendored binary,
400
+ a hand-rolled substitute, or an extraction prefix dropped on your own `PATH`: the architect decides
401
+ whether the image change is work to schedule. An extraction a harness builds for its own runs is
402
+ not improvising and is not what this rules out (the Dispatch e2e stack, above): the two cases part
403
+ on "The test: where it lands, and what it leaves resolving", which the convenience case fails.
404
+
405
+ The operator's side of this — why the image carries those five clients rather than anything else,
406
+ the five priced options with their measurements, and what the clients cost the image — is in
407
+ `docs/kubernetes.md`, the subsection "The tools a role's own work needs".