@sjawhar/opencode-legion-envoy 5.4.5 → 5.5.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.
@@ -14205,7 +14205,7 @@ var dispatchToolSpecs = [
14205
14205
  ops: [{ op: "delete_column", block: "table-123", index: 1 }],
14206
14206
  precondition: { blocks: [{ id: "table-123", token: "sha256:current-table-token" }] }
14207
14207
  },
14208
- description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose - a list item, quote, typed block or footnote definition left holding only the emptied paragraph keeps it. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. A hard break in with is itself INVALID_OP when the matched text is in a heading or a table cell, which are written on one line. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. A delete or retype that would take an ask block out of the document while its ask is open is refused, with nothing sent. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "The result carries the document token this edit produced, so a chain of guarded edits passes each result's token as the next edit's precondition with no dispatch_doc_read between them. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + "A change a browser removes while the edit is in flight is never reported as applied: EDIT_LOST_TO_CONCURRENT_CHANGE means the write was refused and nothing was written, so re-read the document and decide again, as with PRECONDITION_FAILED; lost_ops on a successful result names operations whose text the live document no longer has, because the deletion landed after the version was written. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_POINTER}`,
14208
+ description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. A task item (\`- [ ]\` / \`- [x]\`) is ticked the same way: when find matches from the start of the item's text and with opens with '[x] ' or '[ ] ', the opening sets the item's box and the rest is its new text (replace(find="Write it", with="[x] Write it") ticks it); the same opening over a plain list item or a paragraph stays literal text. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose - a list item, quote, typed block or footnote definition left holding only the emptied paragraph keeps it. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. A hard break in with is itself INVALID_OP when the matched text is in a heading or a table cell, which are written on one line. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. A delete or retype that would take an ask block out of the document while its ask is open is refused, with nothing sent. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "The result carries the document token this edit produced, so a chain of guarded edits passes each result's token as the next edit's precondition with no dispatch_doc_read between them. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + "A change a browser removes while the edit is in flight is never reported as applied: EDIT_LOST_TO_CONCURRENT_CHANGE means the write was refused and nothing was written, so re-read the document and decide again, as with PRECONDITION_FAILED; lost_ops on a successful result names operations whose text the live document no longer has, because the deletion landed after the version was written. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_POINTER}`,
14209
14209
  arguments: (z2) => ({
14210
14210
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14211
14211
  project: z2.string().describe("Project key owning the document.").optional(),
@@ -14214,7 +14214,7 @@ var dispatchToolSpecs = [
14214
14214
  ops: z2.array(z2.object({
14215
14215
  op: z2.enum(DOC_EDIT_OPS).describe("Edit operation."),
14216
14216
  find: z2.string().describe("Text of the target as rendered, for replace or delete; inline markdown (**bold**, `code`) is tolerated and must be balanced; a leading '# ' matches a heading. A delete of a block's entire text removes the block.").optional(),
14217
- with: z2.string().describe("Replacement text for replace: inside a code block, the code's literal text as sent (line breaks at its end do not survive a read); text that would read as block syntax at a line start, such as '---' over a paragraph, is stored escaped and reads back as those characters, so a rule is added with insert beside the paragraph; elsewhere parsed as inline markdown within the matched block; a marker of a different kind from the block's own is literal text, one of the same kind is refused unless it is a heading rename (where a level named by find is what lets with change it), a backslash escape keeps prose that merely looks like a marker, a block marker after a hard line break is refused because replace cannot open a new block, and any non-empty value that renders to no text is refused - only an empty value deletes the match. A CR LF or a lone carriage return in it is written as a line feed.").optional(),
14217
+ with: z2.string().describe("Replacement text for replace: inside a code block, the code's literal text as sent (line breaks at its end do not survive a read); text that would read as block syntax at a line start, such as '---' over a paragraph, is stored escaped and reads back as those characters, so a rule is added with insert beside the paragraph; elsewhere parsed as inline markdown within the matched block; a marker of a different kind from the block's own is literal text, one of the same kind is refused unless it is a heading rename (where a level named by find is what lets with change it), a backslash escape keeps prose that merely looks like a marker, a leading '[x] ' or '[ ] ' over a task item's text matched from its start sets the item's checkbox, a block marker after a hard line break is refused because replace cannot open a new block, and any non-empty value that renders to no text is refused - only an empty value deletes the match. A CR LF or a lone carriage return in it is written as a line feed.").optional(),
14218
14218
  occurrence: z2.number({ int: true, min: 0 }).describe("Optional zero-based match occurrence.").optional(),
14219
14219
  markdown: z2.string().describe("Markdown to insert; a CR LF or a lone carriage return in it is written as a line feed.").optional(),
14220
14220
  after: z2.string().describe(`Insert or move after this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>", "block:<id>".`).optional(),
@@ -14284,7 +14284,7 @@ var dispatchToolSpecs = [
14284
14284
  {
14285
14285
  name: "dispatch_read",
14286
14286
  example: { issue: "DSP-1" },
14287
- description: "Read an issue or project-document summary, targeted ask, or targeted comment reply chain, or the conversation " + "a message belongs to. Do not use it for document contents; use dispatch_doc_read instead. Supply ref, issue, " + "or project plus artifact; or message alone, which reads a human's direct message to this session and every " + "reply to it (they belong to no issue). " + "An anchored comment or ask also says where its quote sits, as `Position:`: the block's path from the top, " + "and in a table the row (0 is the header), the cells before the anchored one, and the column's header; " + "`Position: unavailable (<code>)` when Dispatch could not read the document: `DOC_SERVICE_UNAVAILABLE` " + "(try again shortly), `DOC_SCHEMA` (the document needs repair), `DOCUMENT_UNLOADABLE` (the document " + "needs a rebuild) or `INTERNAL`. " + "Every read ends with `Referenced by:` (what cites or hangs off this node, each with its dispatch:// address, " + "an excerpt, and when) and `Links:` (what it cites), so tracing provenance is one call. " + OWNER_REFERENCE,
14287
+ description: "Read an issue or project-document summary, targeted ask, or targeted comment reply chain, or the conversation " + "a message belongs to. Do not use it for document contents; use dispatch_doc_read instead. Supply ref, issue, " + "or project plus artifact; or message alone, which reads a human's direct message to this session and every " + "reply to it (they belong to no issue). " + "An anchored comment or ask also says where its quote sits, as `Position:`: the block's path from the top, " + "and in a table the row (0 is the header), the cells before the anchored one, and the column's header; " + "`Position: unavailable (<code>)` when Dispatch could not read the document: `DOC_SERVICE_UNAVAILABLE` " + "(try again shortly), `DOC_SCHEMA` (the document needs repair), `DOCUMENT_UNLOADABLE` (the document " + "needs a rebuild) or `INTERNAL`. " + "An issue read carries a `Progress:` line, `tasks 3/7, children 2/5`: its spec's task-list items " + "(`- [ ]` / `- [x]`, nested lists included) and its direct children, each done of total and each only " + "when it has any (`Progress: none` otherwise). " + "Every read ends with `Referenced by:` (what cites or hangs off this node, each with its dispatch:// address, " + "an excerpt, and when) and `Links:` (what it cites), so tracing provenance is one call. " + OWNER_REFERENCE,
14288
14288
  arguments: (z2) => ({
14289
14289
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14290
14290
  project: z2.string().describe("Project key owning the document.").optional(),
@@ -14318,7 +14318,7 @@ var dispatchToolSpecs = [
14318
14318
  {
14319
14319
  name: "dispatch_issues",
14320
14320
  example: { project: "PROJ", limit: 250, offset: 250 },
14321
- 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, 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.",
14321
+ 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.",
14322
14322
  arguments: (z2) => ({
14323
14323
  project: z2.string().describe("Project key to list issues from."),
14324
14324
  status: z2.enum(ISSUE_STATUSES).describe("Optional lifecycle status filter.").optional(),
@@ -16045,6 +16045,17 @@ function routeText(issue2, titles) {
16045
16045
  };
16046
16046
  return issue2.route + (issue2.route_status == null ? "" : reach[issue2.route_status]);
16047
16047
  }
16048
+ function progressText(progress, sep) {
16049
+ if (progress === undefined)
16050
+ return "";
16051
+ const parts = [];
16052
+ if (progress.tasks !== null)
16053
+ parts.push(`tasks ${progress.tasks.done}/${progress.tasks.total}`);
16054
+ if (progress.children !== null) {
16055
+ parts.push(`children ${progress.children.done}/${progress.children.total}`);
16056
+ }
16057
+ return parts.join(sep);
16058
+ }
16048
16059
  function issueSummary(issue2, events, references, graph, titles) {
16049
16060
  const asks = issue2.open_asks;
16050
16061
  const spec = issue2.artifacts?.find((artifact) => artifact.primary);
@@ -16070,6 +16081,7 @@ function issueSummary(issue2, events, references, graph, titles) {
16070
16081
  `Labels: ${issue2.labels.length === 0 ? "none" : issue2.labels.join(", ")}`,
16071
16082
  componentsLine(issue2.components),
16072
16083
  `Route: ${routeText(issue2, titles)}`,
16084
+ `Progress: ${progressText(issue2.progress, ", ") || "none"}`,
16073
16085
  ...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
16074
16086
  "External links:",
16075
16087
  ...issue2.external_links.length === 0 ? ["- none"] : issue2.external_links.map((link) => `- ${link.url}${link.kind === undefined ? "" : ` (${link.kind})`}`),
@@ -16757,7 +16769,8 @@ async function executeDispatchTool(input) {
16757
16769
  route: row.route ?? null,
16758
16770
  route_status: row.route_status ?? null,
16759
16771
  route_holder: row.route_holder ?? null,
16760
- updated_at: row.updated_at
16772
+ updated_at: row.updated_at,
16773
+ progress: row.progress
16761
16774
  }));
16762
16775
  const titles = await liveSessionTitles(client, rows.some((row) => holdsSession(row.claim)));
16763
16776
  const isPartial = offset !== 0 || rows.length !== total;
@@ -16765,7 +16778,10 @@ async function executeDispatchTool(input) {
16765
16778
  return {
16766
16779
  text: rows.length === 0 ? `No issues in ${project}.${isPartial ? ` (${showing})` : ""}` : [
16767
16780
  `${rows.length} ${rows.length === 1 ? "issue" : "issues"} in ${project}` + (isPartial ? ` (${showing})` : ""),
16768
- ...rows.map((row) => `${row.key} [${row.status}]${row.priority === null ? "" : ` P${row.priority}`} ${row.title}` + (row.open_asks === 0 ? "" : ` \xB7 ${row.open_asks} open ${row.open_asks === 1 ? "ask" : "asks"}`) + (row.claim === null ? "" : ` \xB7 claimed by ${claimText(row.claim, titles)}`) + (row.route === null || row.route_status === "live" || row.route_status === null ? "" : ` \xB7 route ${routeText(row)}`))
16781
+ ...rows.map((row) => {
16782
+ const progress = progressText(row.progress, " \xB7 ");
16783
+ return `${row.key} [${row.status}]${row.priority === null ? "" : ` P${row.priority}`} ${row.title}` + (row.open_asks === 0 ? "" : ` \xB7 ${row.open_asks} open ${row.open_asks === 1 ? "ask" : "asks"}`) + (row.claim === null ? "" : ` \xB7 claimed by ${claimText(row.claim, titles)}`) + (progress === "" ? "" : ` \xB7 ${progress}`) + (row.route === null || row.route_status === "live" || row.route_status === null ? "" : ` \xB7 route ${routeText(row)}`);
16784
+ })
16769
16785
  ].join(`
16770
16786
  `),
16771
16787
  details: { issues: rows, total, offset, limit }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "5.4.5",
3
+ "version": "5.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -74,7 +74,11 @@ which is written as text with a backslash escape (`1999\. was a year`) — omit
74
74
  plus `delete` to change the block's kind, level or number. The one exception is a heading rename whose `find` carried a heading
75
75
  marker: `replace(find="## Old", with="## New")` gives `## New`. A different level in `with` applies only when `find` named the
76
76
  heading's actual level — `find="## Old"`, `with="### New"` retitles and makes it an h3 — because `# ` is the level-blind selector,
77
- so `find="# Old"` renames the text and keeps whatever level it selected. `with` that forms more than one
77
+ so `find="# Old"` renames the text and keeps whatever level it selected. A task item (`- [ ]` / `- [x]`) is ticked the same way:
78
+ when `find` matches from the start of the item's text and `with` opens with `[x] ` or `[ ] `, the opening sets the item's
79
+ checkbox and the rest is its new text — `replace(find="Write it", with="[x] Write it")` ticks it, keeping its block id — since
80
+ the box is the item's attribute, not text, and written as text it would read `- [ ] [x] …`. The same opening over a plain list
81
+ item or a paragraph stays literal text, as any marker of another kind does. `with` that forms more than one
78
82
  paragraph is rejected (`INVALID_OP` on `with`) — see the recipe for a multi-paragraph rewrite below; so is any non-empty `with` that
79
83
  renders to no text, which a line indented four spaces or a tab does (markdown reads that as a code block), as does whitespace
80
84
  alone. An empty `with` deletes the matched text on purpose; a list item, quote, typed block or footnote definition left
@@ -46,10 +46,14 @@ takes this order: write, commit and push the handoff; submit the review of the h
46
46
  made, by its SHA; then complete. An approval waits for the CI verdict to settle green at that head
47
47
  before you submit it, since an approval stands only on green checks and GitHub can dismiss one
48
48
  once the head moves, and a verdict that settles red there makes the round's decision a request for
49
- changes naming the failing checks; a request for changes does not wait, since it stands whatever
50
- CI says and the issue leaves reviewing with it. The verdict is of the checks and workflows the
51
- base branch requires, the set READY checks: red when one of them failed, and never red for a check
52
- the base branch does not require. A required check that was cancelled, or that the head's checks
49
+ changes naming the failing checks, unless only review workflows the project declares
50
+ (`projects.<KEY>.review_workflows`) are red on their own findings: then you answer their threads,
51
+ have them resolved with `legion threads resolve` and re-run the failed run, as your role prompt
52
+ says, and approve once it passes. Any other red required workflow is a failing check like any
53
+ other. A request for changes does not wait, since it stands whatever CI says and the issue leaves
54
+ reviewing with it. The verdict is of the checks and workflows the base branch requires, the set
55
+ READY checks: red when one of them failed, and never red for a check the base branch does not
56
+ require. A required check that was cancelled, or that the head's checks
53
57
  settled without, leaves no verdict until a later settlement decides it, since a run can be
54
58
  cancelled or not yet queued when the head settles; a required workflow's run on the head that is
55
59
  still going or has not happened leaves none either. A review of a head the handoff push
@@ -2,7 +2,8 @@
2
2
 
3
3
  Part of `skill://legion-worker`. Read it when you reply to, accept, or resolve a review thread,
4
4
  or run `legion threads resolve`: the implementer after every push that answers a review, the
5
- merger before READY, and the reviewer, who answers threads on every re-review and runs nothing.
5
+ merger before READY, and the reviewer, who answers threads on every re-review and runs the command
6
+ only when a review workflow the project declares is red on a bot's findings.
6
7
  Every path it cites is in sjawhar/legion.
7
8
 
8
9
  - **Threads are dispositioned individually, never resolved in bulk.** Every open review
@@ -15,7 +16,11 @@ Every path it cites is in sjawhar/legion.
15
16
  considers only the newest comment). The review App can reply on a thread but cannot resolve it:
16
17
  GitHub grants resolving a review thread to the pull request's author, and the implementer opens
17
18
  every Legion pull request (`docs/site/src/content/docs/legion/running-legion.md`, "The two
18
- GitHub Apps").
19
+ GitHub Apps"). In the reviewer's pane (`LEGION_ROLE=reviewer`), `legion threads resolve` asks
20
+ the daemon instead (`POST /legion/v1/threads/resolve`), which resolves as the implementer's App
21
+ only the threads a bot outside Legion's role Apps opened whose newest submitted comment is the
22
+ reviewer's `Accepted:`, on the pull request of the reviewer's own issue, and prints the same
23
+ lines; the reviewer never holds the implementer's token.
19
24
  When `LEGION_GRANT_FILE` or `LEGION_GRANT` is set, use `legion threads resolve --pr <number> --repo <owner>/<repo>`.
20
25
  When neither is set, add `--gh` to that command, which applies the fallback's rule below through
21
26
  your own `gh`; where no `legion` command is installed, use `gh api graphql` with the session's
@@ -80,5 +85,9 @@ Every path it cites is in sjawhar/legion.
80
85
  opener's (read the newest comments with `gh api graphql`, never from the PR body). Another
81
86
  opener's thread that a person resolved with GitHub's button, with no `Accepted:`, gates nothing:
82
87
  neither `legion threads resolve` nor the merge queue's gate counts a resolved thread. Resolution
83
- is the pull request author's App's, so your approval never waits on it. The merger resolves
84
- accepted threads that remain open before publishing READY.
88
+ is the pull request author's App's, so your approval never waits on it, except when a review
89
+ workflow the project declares (`projects.<KEY>.review_workflows`) is red on its findings: such a
90
+ workflow passes on a re-run only once its threads are resolved, so you run `legion threads
91
+ resolve` (the daemon resolves the bot threads you accepted) and re-run the failed run before you
92
+ approve, as your role prompt says.
93
+ The merger resolves accepted threads that remain open before publishing READY.