@sjawhar/pi-legion 11.1.0 → 11.3.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
@@ -14158,6 +14158,15 @@ var ArtifactVersionEventPayloadSchema = exports_external.object({
14158
14158
  version: exports_external.object({ number: exports_external.number().optional(), summary: exports_external.string().nullish() }).optional(),
14159
14159
  diff: exports_external.string().optional()
14160
14160
  });
14161
+ var ArtifactHumanEditedEventPayloadSchema = exports_external.object({
14162
+ artifact_id: exports_external.string().optional(),
14163
+ name: exports_external.string().optional(),
14164
+ from_version: exports_external.number().int().optional(),
14165
+ to_version: exports_external.number().int().optional(),
14166
+ editors: exports_external.array(exports_external.string()).optional(),
14167
+ diff: exports_external.string().optional(),
14168
+ diff_truncated: exports_external.boolean().optional()
14169
+ });
14161
14170
  var ArtifactReviewEventPayloadSchema = exports_external.object({
14162
14171
  artifact_id: exports_external.string().optional(),
14163
14172
  name: exports_external.string().optional(),
@@ -14346,6 +14355,8 @@ var AGENT_STREAM_LIMITS = {
14346
14355
  commandNameChars: 100,
14347
14356
  commandDescriptionChars: 200
14348
14357
  };
14358
+ // ../contracts/src/dispatch-block-ref.ts
14359
+ var encoder = new TextEncoder;
14349
14360
  // ../contracts/src/dispatch-text-rules.ts
14350
14361
  var SESSION_ID_PATTERN = "[^/?#\\s\\p{Z}\\p{Cc}<>\"'`\\[\\]|]+";
14351
14362
  var sessionIdOnly = new RegExp(`^${SESSION_ID_PATTERN}$`, "u");
@@ -14550,7 +14561,7 @@ var dispatchToolSpecs = [
14550
14561
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14551
14562
  project: z2.string().describe("Project key owning the document.").optional(),
14552
14563
  artifact: z2.string().describe("Project document artifact id, slug, or filename.").optional(),
14553
- ref: z2.string().describe("Optional dispatch:// reference (issue, document, message, or ask); appended to the question and rendered as a link.").optional(),
14564
+ ref: z2.string().describe("Optional dispatch:// reference (issue, document, block, message, or ask); appended to the question and rendered as a link.").optional(),
14554
14565
  question: z2.string({ max: ASK_QUESTION_MAX }).describe(`${ASK_QUESTION_CONTRACT} At most ${ASK_QUESTION_MAX} characters.`),
14555
14566
  options: z2.array(z2.object({
14556
14567
  label: z2.string().describe("Selectable option label."),
@@ -14695,7 +14706,7 @@ var dispatchToolSpecs = [
14695
14706
  ops: [{ op: "delete_column", block: "table-123", index: 1 }],
14696
14707
  precondition: { blocks: [{ id: "table-123", token: "sha256:current-table-token" }] }
14697
14708
  },
14698
- 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>"; dispatch doc-read --blocks lists every block's id, type, opening words and token, and each table's rows and cells with the indexes delete_row and delete_column take (GET /api/v1/artifacts/{artifact UUID}/blocks serves the same list). ` + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch doc-read, or block {id, token} values from dispatch doc-read --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. " + "One replace per paragraph: more new paragraphs go in one insert after the last one you rewrote, since two inserts at one anchor come out reversed; read the passage back with dispatch doc-read afterwards. " + `The spec holds requirements, design, and decisions - never progress, status, or timestamps; a record of open items or progress is a document of its own. ${OWNER_REFERENCE} ${SPEC_WRITING_POINTER}`,
14709
+ 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. In a table cell, with is the cell's text: write a | in it bare, in code too, since code whose text has an odd run of backslashes right before a | cannot be stored in a cell and is INVALID_OP, naming the cell. " + "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>"; dispatch doc-read --blocks lists every block's id, type, opening words and token, and each table's rows and cells with the indexes delete_row and delete_column take (GET /api/v1/artifacts/{artifact UUID}/blocks serves the same list). ` + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch doc-read, or block {id, token} values from dispatch doc-read --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. " + "One replace per paragraph: more new paragraphs go in one insert after the last one you rewrote, since two inserts at one anchor come out reversed; read the passage back with dispatch doc-read afterwards. " + "A block id written as {#id} longer than 256 bytes is stored, but its block is no graph node, so no reference reaches it. " + `The spec holds requirements, design, and decisions - never progress, status, or timestamps; a record of open items or progress is a document of its own. ${OWNER_REFERENCE} ${SPEC_WRITING_POINTER}`,
14699
14710
  arguments: (z2) => ({
14700
14711
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14701
14712
  project: z2.string().describe("Project key owning the document.").optional(),
@@ -14755,7 +14766,7 @@ var dispatchToolSpecs = [
14755
14766
  name: "dispatch_artifact",
14756
14767
  example: { issue: "DSP-1", name: "design.md", content: `# Design
14757
14768
  ` },
14758
- description: "Attach a local file or inline text as an issue artifact or project document. Do not use it to edit a live document; use " + "dispatch doc-edit instead. Never upload a document's own name to change it (spec.md replaces the spec whole); edit it with dispatch doc-edit. Uploading the same name again makes its next version. Exactly one of path or content is required; a markdown document is at most 1 MiB and any other file at most 25 MiB. " + "Markdown is also refused with 413 CAP_EXCEEDED when it makes more than 65,536 elements (a block weighs 3, and an empty list item, quote, footnote definition or typed block 3 more for the empty paragraph it holds; a table cell 4, a hard line break 3, an image 3, a piece of inline HTML 2, an autolink 2, a footnote reference 2; inline syntax, marks, lines of text and backslash escapes or character references 1 each), or when a new version would leave the document's stored markdown (what its text reads back as) longer than 1 MiB or making more than 65,536 elements, and longer or heavier than before; shorten it or split it across documents. " + "Markdown holding an ask block whose body breaks its content rule (one or more question paragraphs, then at most one bullet list of options, last) is refused with 400 INVALID_ASK_BLOCK; a new version of a document is held to it only for the asks it writes or changes. " + `${OWNER_REFERENCE}`,
14769
+ description: "Attach a local file or inline text as an issue artifact or project document. Do not use it to edit a live document; use " + "dispatch doc-edit instead. Never upload a document's own name to change it (spec.md replaces the spec whole); edit it with dispatch doc-edit. Uploading the same name again makes its next version. Exactly one of path or content is required; a markdown document is at most 1 MiB and any other file at most 25 MiB. " + "Markdown is also refused with 413 CAP_EXCEEDED when it makes more than 65,536 elements (a block weighs 3, and an empty list item, quote, footnote definition or typed block 3 more for the empty paragraph it holds; a table cell 4, a hard line break 3, an image 3, a piece of inline HTML 2, an autolink 2, a footnote reference 2; inline syntax, marks, lines of text and backslash escapes or character references 1 each), or when a new version would leave the document's stored markdown (what its text reads back as) longer than 1 MiB or making more than 65,536 elements, and longer or heavier than before; shorten it or split it across documents. " + "In a table row, write every | in a cell as \\|, in code too: a | after two backslashes ends the cell, and a row with more cells than its table is refused. " + "Markdown holding an ask block whose body breaks its content rule (one or more question paragraphs, then at most one bullet list of options, last) is refused with 400 INVALID_ASK_BLOCK; a new version of a document is held to it only for the asks it writes or changes. " + `${OWNER_REFERENCE}`,
14759
14770
  arguments: (z2) => ({
14760
14771
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14761
14772
  project: z2.string().describe("Project key for an unlinked document.").optional(),
@@ -14775,12 +14786,12 @@ var dispatchToolSpecs = [
14775
14786
  {
14776
14787
  name: "dispatch_read",
14777
14788
  example: { issue: "DSP-1" },
14778
- description: "Read an issue or 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, " + "issue plus artifact (one of its documents), 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). A document summary names its issue, reference, versions and approval. " + "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). " + `An issue read, and its dispatch://KEY/log, lists the issue's last ${DEFAULT_ISSUE_READ_EVENTS} events, oldest first; ` + "--events N lists the last N, and --all-events every one. " + "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. " + "The pictures the shown messages, asks and comments embed come back as images you see, newest first, at most 8 " + "and 10 MiB of them per read, each a PNG, JPEG, GIF or WebP of at most 3,750,000 bytes (5 MB of base64); a `Pictures:` section names each " + "one shown, in order, and the rest by reference, for dispatch doc-read. A picture this session was already shown is named, not sent again " + "(every request carries the session's history, and the provider refuses one over 32 MB); dispatch doc-read shows it again. " + OWNER_REFERENCE,
14789
+ description: "Read an issue or 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, " + "issue plus artifact (one of its documents), 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). A document summary names its issue, reference, versions and approval. " + "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). " + `An issue read, and its dispatch://KEY/log, lists the issue's last ${DEFAULT_ISSUE_READ_EVENTS} events, oldest first; ` + "--events N lists the last N, and --all-events every one. " + "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. " + "A block read prints `Block:`, `Document:`, `Changed in version:` and `Text:`, then those two sections; a pinned one adds " + "the block's text at that version and whether it is unchanged, changed or removed since, and an absent block, unpinned, " + "reads `No block <id> in <document>.` " + "The pictures the shown messages, asks and comments embed come back as images you see, newest first, at most 8 " + "and 10 MiB of them per read, each a PNG, JPEG, GIF or WebP of at most 3,750,000 bytes (5 MB of base64); a `Pictures:` section names each " + "one shown, in order, and the rest by reference, for dispatch doc-read. A picture this session was already shown is named, not sent again " + "(every request carries the session's history, and the provider refuses one over 32 MB); dispatch doc-read shows it again. " + OWNER_REFERENCE,
14779
14790
  arguments: (z2) => ({
14780
14791
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14781
14792
  project: z2.string().describe("Project key owning the document.").optional(),
14782
14793
  artifact: z2.string().describe("Document artifact id, slug, or filename: one of the issue's, or a project document.").optional(),
14783
- ref: z2.string().describe("Optional dispatch:// reference: dispatch://KEY, dispatch://KEY/spec, " + "dispatch://KEY/artifact/<slug>[@vN], dispatch://KEY/ask/<id>, dispatch://KEY/comment/<id>, " + "dispatch://KEY/message/<id>, dispatch://PROJECT/artifact/<slug>[@vN], and its " + "/ask/<id> and /comment/<id>; or the dashboard URL of one.").optional(),
14794
+ ref: z2.string().describe("Optional dispatch:// reference: dispatch://KEY, dispatch://KEY/spec, " + "dispatch://KEY/artifact/<slug>[@vN], dispatch://KEY/ask/<id>, dispatch://KEY/comment/<id>, " + "dispatch://KEY/message/<id>, dispatch://PROJECT/artifact/<slug>[@vN], and its " + "/ask/<id> and /comment/<id>; or the dashboard URL of one. A block: " + "dispatch://KEY/spec#b-<block id>[@vN], dispatch://KEY/artifact/<slug>#b-<block id>[@vN] or " + "dispatch://PROJECT/artifact/<slug>#b-<block id>[@vN], the id percent-encoded except ASCII " + "letters, digits and -.").optional(),
14784
14795
  message: z2.string().describe("A message id (uuid): reads the conversation it belongs to, the root message and every " + "reply. Name nothing else with it.").optional(),
14785
14796
  events: z2.number({ int: true, min: 1 }).describe(`How many of the issue's newest events an issue read or its dispatch://KEY/log lists, oldest first; default ${DEFAULT_ISSUE_READ_EVENTS}. Name no --all-events with it.`).optional(),
14786
14797
  all_events: z2.boolean().describe(`List every one of the issue's events, oldest first, on an issue read or its dispatch://KEY/log, in place of the last ${DEFAULT_ISSUE_READ_EVENTS}. Name no --events with it.`).optional()
@@ -15607,7 +15618,7 @@ var picturesShown = new Map;
15607
15618
 
15608
15619
  // ../envoy-client/src/dispatch-execute.ts
15609
15620
  var triageAdviceShown = new Set;
15610
- var refGrammarProblem = "ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename), or " + "dispatch://agent/<session id>/artifact/<slug> (a picture in a conversation on the Agents page, " + "for dispatch doc-read); a dashboard URL on this Dispatch server is accepted too";
15621
+ var refGrammarProblem = "ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename), " + "dispatch://KEY-1/spec#b-<block id>, dispatch://KEY-1/artifact/<slug>#b-<block id> or " + "dispatch://PROJECT/artifact/<slug>#b-<block id> (one block of a document, its id " + "percent-encoded, optionally pinned to a version of the document with @v<N> after it; for " + "dispatch read), or " + "dispatch://agent/<session id>/artifact/<slug> (a picture in a conversation on the Agents page, " + "for dispatch doc-read); a dashboard URL on this Dispatch server is accepted too";
15611
15622
  var toolSchemas = new Map;
15612
15623
  function artifactByReference(artifacts, artifactReference, owner, canonical) {
15613
15624
  const bySlug = artifacts.find((candidate) => candidate.slug === artifactReference);
@@ -15748,6 +15759,7 @@ var issueKey = exports_external.string().regex(DISPATCH_KEY_PATTERN);
15748
15759
  var timestamp = exports_external.iso.datetime({ offset: true });
15749
15760
  var LEGION_WORKFLOW_PHASES = [
15750
15761
  "admitted",
15762
+ "awaiting_person",
15751
15763
  "planning",
15752
15764
  "implementing",
15753
15765
  "testing",
@@ -15860,6 +15872,32 @@ var legionCheckView = exports_external.strictObject({
15860
15872
  nextWakeAt: timestamp,
15861
15873
  last: exports_external.boolean()
15862
15874
  });
15875
+ var LEGION_WAIT_ANSWERS = [
15876
+ "ask",
15877
+ "retracted",
15878
+ "closed",
15879
+ "reply",
15880
+ "turn",
15881
+ "approved",
15882
+ "changes_requested",
15883
+ "version"
15884
+ ];
15885
+ var legionWaitTimes = {
15886
+ since: timestamp.optional(),
15887
+ answeredAt: timestamp.optional(),
15888
+ answeredBy: exports_external.enum(LEGION_WAIT_ANSWERS).optional()
15889
+ };
15890
+ var legionWaitingOn = exports_external.discriminatedUnion("kind", [
15891
+ exports_external.strictObject({ kind: exports_external.literal("ask"), ask: nonEmptyString, ...legionWaitTimes }),
15892
+ exports_external.strictObject({
15893
+ kind: exports_external.literal("gate"),
15894
+ gateVersion: exports_external.number().int().positive(),
15895
+ artifactId: nonEmptyString,
15896
+ ...legionWaitTimes
15897
+ })
15898
+ ]).refine((wait) => wait.answeredAt === undefined === (wait.answeredBy === undefined), {
15899
+ message: "answeredAt and answeredBy appear together"
15900
+ });
15863
15901
  var legionPendingStatusWrite = exports_external.strictObject({
15864
15902
  issue: nonEmptyString,
15865
15903
  payload: exports_external.record(exports_external.string(), exports_external.unknown()),
@@ -15892,6 +15930,7 @@ var legionIssue = exports_external.strictObject({
15892
15930
  pullRequest: legionPullRequestView.optional(),
15893
15931
  designGate: legionGateView.optional(),
15894
15932
  slot: legionSlotView.optional(),
15933
+ waitingOn: legionWaitingOn.optional(),
15895
15934
  check: legionCheckView.optional()
15896
15935
  });
15897
15936
  var daemonInfo = exports_external.strictObject({
@@ -16183,6 +16222,71 @@ async function callReadyWithRetry(label, call) {
16183
16222
  }
16184
16223
  }
16185
16224
  }
16225
+ var DAEMON_RESTART_BUDGET_MS = 10 * 60000;
16226
+ var DAEMON_RESTART_FIRST_DELAY_MS = 500;
16227
+ var DAEMON_RESTART_MAX_DELAY_MS = 30000;
16228
+ function daemonUnreachable(error48) {
16229
+ if (error48 instanceof LegionDaemonApiError) {
16230
+ return (error48.status === 502 || error48.status === 503) && error48.code === undefined ? "dropped" : undefined;
16231
+ }
16232
+ if (!(error48 instanceof Error))
16233
+ return;
16234
+ const code = "code" in error48 && typeof error48.code === "string" ? error48.code : undefined;
16235
+ if (code === "ConnectionRefused" || code === "ECONNREFUSED")
16236
+ return "refused";
16237
+ if (code === "ConnectionClosed" || code === "ECONNRESET" || code === "EPIPE")
16238
+ return "dropped";
16239
+ return error48 instanceof TypeError && (error48.message === "Failed to fetch" || error48.message === "fetch failed") ? "dropped" : undefined;
16240
+ }
16241
+ async function retryWhileDaemonRestarts(label, call, options = {}) {
16242
+ const { signal } = options;
16243
+ const now = options.now ?? Date.now;
16244
+ const sleep = options.sleep ?? ((ms, abort) => {
16245
+ const wait = Promise.withResolvers();
16246
+ const timer = setTimeout(wait.resolve, ms);
16247
+ abort?.addEventListener("abort", () => {
16248
+ clearTimeout(timer);
16249
+ wait.resolve();
16250
+ }, { once: true });
16251
+ return wait.promise;
16252
+ });
16253
+ const deadline = now() + DAEMON_RESTART_BUDGET_MS;
16254
+ let delay = DAEMON_RESTART_FIRST_DELAY_MS;
16255
+ let told = false;
16256
+ let dropped = false;
16257
+ let lastFailure;
16258
+ for (;; ) {
16259
+ if (signal?.aborted && lastFailure !== undefined) {
16260
+ throw new Error(`${label}: the Legion daemon was unreachable (${messageFor(lastFailure)}) and the call was aborted${dropped ? "; an attempt lost its connection before the daemon answered and may have been applied, so read the record before calling again" : "; call it again once the daemon is back"}`);
16261
+ }
16262
+ try {
16263
+ return await call();
16264
+ } catch (error48) {
16265
+ const unreachable = daemonUnreachable(error48);
16266
+ if (unreachable === undefined) {
16267
+ if (dropped && error48 instanceof LegionDaemonApiError) {
16268
+ error48.message += ` (an earlier attempt of ${label} lost its connection before the daemon answered, and may have been applied: read the record before calling again)`;
16269
+ }
16270
+ throw error48;
16271
+ }
16272
+ lastFailure = error48;
16273
+ dropped ||= unreachable === "dropped";
16274
+ const remaining = deadline - now();
16275
+ if (remaining <= 0) {
16276
+ throw new Error(`${label}: the Legion daemon was unreachable (${messageFor(error48)}) and stayed so for ${DAEMON_RESTART_BUDGET_MS / 60000} minutes${dropped ? "; an attempt lost its connection before the daemon answered and may have been applied, so read the record before calling again" : "; call it again once the daemon is back"}`);
16277
+ }
16278
+ if (!told && !signal?.aborted) {
16279
+ told = true;
16280
+ const message = `The Legion daemon is unreachable, most likely restarting (${messageFor(error48)}); ${label} is being retried for up to ${DAEMON_RESTART_BUDGET_MS / 60000} minutes.`;
16281
+ console.error(`[legion] ${message}`);
16282
+ options.onRetrying?.(message);
16283
+ }
16284
+ if (!signal?.aborted)
16285
+ await sleep(Math.min(delay, remaining), signal);
16286
+ delay = Math.min(delay * 2, DAEMON_RESTART_MAX_DELAY_MS);
16287
+ }
16288
+ }
16289
+ }
16186
16290
  function parseStrictly(schema, body) {
16187
16291
  let json2;
16188
16292
  try {
@@ -17047,7 +17151,7 @@ async function bootLegionClaim(host2) {
17047
17151
  // package.json
17048
17152
  var package_default = {
17049
17153
  name: "@sjawhar/pi-legion",
17050
- version: "11.1.0",
17154
+ version: "11.3.0",
17051
17155
  license: "Apache-2.0",
17052
17156
  type: "module",
17053
17157
  omp: {
@@ -17059,7 +17163,7 @@ var package_default = {
17059
17163
  ]
17060
17164
  },
17061
17165
  legion: {
17062
- daemonApiVersion: 39
17166
+ daemonApiVersion: 40
17063
17167
  },
17064
17168
  repository: {
17065
17169
  type: "git",
@@ -17561,36 +17665,8 @@ async function executePhaseOp(runtime, input) {
17561
17665
  switch (args.op) {
17562
17666
  case "read_record":
17563
17667
  return { record: recordFrom(await daemon.state(), args.issue) };
17564
- case "handoff_complete": {
17565
- const record2 = recordFrom(await daemon.state(), claim.issue);
17566
- if (claim.role === "implementer" && record2.phase === "done") {
17567
- if (record2.check !== undefined) {
17568
- if (args.verdict === undefined) {
17569
- throw new Error("handoff_complete on a production check names a verdict of pass, pending or fail");
17570
- }
17571
- if (args.ready === true) {
17572
- throw new Error("handoff_complete on a production check is no READY: leave ready out");
17573
- }
17574
- }
17575
- return daemon.handoffComplete({
17576
- grantId: await mintClaimGrant(daemon, claim),
17577
- summary: args.summary,
17578
- verdict: args.verdict ?? "",
17579
- ready: false
17580
- });
17581
- }
17582
- if (args.verdict === "pending") {
17583
- throw new Error("handoff_complete's verdict is pass or fail on a phase completion; pending is a production check's");
17584
- }
17585
- const commit = await runtime.handoffCommit(record2.phase, claim.role);
17586
- return daemon.handoffComplete({
17587
- grantId: await mintClaimGrant(daemon, claim),
17588
- summary: args.summary,
17589
- verdict: args.verdict ?? "",
17590
- ready: args.ready ?? false,
17591
- commit
17592
- });
17593
- }
17668
+ case "handoff_complete":
17669
+ return retryWhileDaemonRestarts("handoff_complete", () => completeHandoff(runtime, args), runtime.restart);
17594
17670
  case "request_backward_move": {
17595
17671
  let issue2;
17596
17672
  if (claim.role === "architect") {
@@ -17611,6 +17687,37 @@ async function executePhaseOp(runtime, input) {
17611
17687
  }
17612
17688
  }
17613
17689
  }
17690
+ async function completeHandoff(runtime, args) {
17691
+ const { daemon, claim } = runtime;
17692
+ const record2 = recordFrom(await daemon.state(), claim.issue);
17693
+ if (claim.role === "implementer" && record2.phase === "done") {
17694
+ if (record2.check !== undefined) {
17695
+ if (args.verdict === undefined) {
17696
+ throw new Error("handoff_complete on a production check names a verdict of pass, pending or fail");
17697
+ }
17698
+ if (args.ready === true) {
17699
+ throw new Error("handoff_complete on a production check is no READY: leave ready out");
17700
+ }
17701
+ }
17702
+ return daemon.handoffComplete({
17703
+ grantId: await mintClaimGrant(daemon, claim),
17704
+ summary: args.summary,
17705
+ verdict: args.verdict ?? "",
17706
+ ready: false
17707
+ });
17708
+ }
17709
+ if (args.verdict === "pending") {
17710
+ throw new Error("handoff_complete's verdict is pass or fail on a phase completion; pending is a production check's");
17711
+ }
17712
+ const commit = await runtime.handoffCommit(record2.phase, claim.role);
17713
+ return daemon.handoffComplete({
17714
+ grantId: await mintClaimGrant(daemon, claim),
17715
+ summary: args.summary,
17716
+ verdict: args.verdict ?? "",
17717
+ ready: args.ready ?? false,
17718
+ commit
17719
+ });
17720
+ }
17614
17721
 
17615
17722
  // ../pi-shared/src/tool-result.ts
17616
17723
  function toolSuccess(text, details = {}, images = []) {
@@ -17685,17 +17792,35 @@ function assertOperationInput(parameters, operation2) {
17685
17792
  }
17686
17793
  }
17687
17794
  function createLegionTool(deps) {
17688
- const { pi, daemon, session, controllerGrant, onPhaseCompleted, handoffCommit, resolveDocument } = deps;
17795
+ const {
17796
+ pi,
17797
+ daemon,
17798
+ session,
17799
+ controllerGrant,
17800
+ onPhaseCompleted,
17801
+ handoffCommit,
17802
+ resolveDocument,
17803
+ restartClock
17804
+ } = deps;
17689
17805
  return {
17690
17806
  name: "legion",
17691
17807
  label: "legion",
17692
17808
  description: legionToolDescription({
17693
17809
  envoyPublish: "envoy_publish",
17694
- after: PHASE_OP_SENTENCES.requestBackwardMove + "sign_off is the architect's acceptance of an issue in accepting, moving it to retro; the daemon refuses it in every " + "other phase (SIGNOFF_OUTSIDE_ACCEPTING) and refuses it SIGNOFF_CHILD_IN_WORKFLOW while any issue beneath it is still in " + "the workflow, naming each of them: accept once the last of them is done, or take one out with park_child. " + "The controller's read_state returns the daemon's whole state and set_status moves an issue to todo, backlog or icebox. "
17810
+ after: PHASE_OP_SENTENCES.requestBackwardMove + "sign_off is the architect's acceptance of an issue in accepting, moving it to retro; the daemon refuses it in every " + "other phase (SIGNOFF_OUTSIDE_ACCEPTING), and issues still running beneath it do not hold it: a root's close waits " + "for them instead. " + "The controller's read_state returns the daemon's whole state and set_status moves an issue to todo, backlog or icebox. "
17695
17811
  }),
17696
17812
  defaultInactive: true,
17697
17813
  parameters: toolSchema(pi),
17698
- execute: async (_id, parameters, _signal, _onUpdate, context) => {
17814
+ execute: async (_id, parameters, signal, onUpdate, context) => {
17815
+ const restart = {
17816
+ ...restartClock,
17817
+ ...signal === undefined ? {} : { signal },
17818
+ onRetrying: (message) => {
17819
+ if (typeof onUpdate === "function") {
17820
+ onUpdate({ content: [{ type: "text", text: message }], details: {} });
17821
+ }
17822
+ }
17823
+ };
17699
17824
  try {
17700
17825
  const active = session(context);
17701
17826
  const operation2 = requiredString2(parameters, "legion", "op");
@@ -17703,7 +17828,7 @@ function createLegionTool(deps) {
17703
17828
  throw new Error(`${operation2} is not available to a ${active.kind} session`);
17704
17829
  }
17705
17830
  if (active.kind !== "controller" && isPhaseOp(operation2)) {
17706
- const answer = await executePhaseOp({ daemon: daemon(), claim: active, handoffCommit }, parameters);
17831
+ const answer = await executePhaseOp({ daemon: daemon(), claim: active, handoffCommit, restart }, parameters);
17707
17832
  if (operation2 === "handoff_complete")
17708
17833
  onPhaseCompleted(context);
17709
17834
  return jsonSuccess(answer);
@@ -17738,8 +17863,12 @@ function createLegionTool(deps) {
17738
17863
  const reference = requiredString2(parameters, operation2, "artifactId");
17739
17864
  const isId = LegionGateRegisterRequest.shape.artifactId.safeParse(reference).success;
17740
17865
  const artifactId = isId ? reference : await resolveDocument(issue2, reference);
17741
- const grantId = await mintClaimGrant(client, active);
17742
- await client.gateRegister({ grantId, issue: issue2, artifactId, version: version2 });
17866
+ await retryWhileDaemonRestarts("register_gate", async () => client.gateRegister({
17867
+ grantId: await mintClaimGrant(client, active),
17868
+ issue: issue2,
17869
+ artifactId,
17870
+ version: version2
17871
+ }), restart);
17743
17872
  return jsonSuccess({});
17744
17873
  }
17745
17874
  case "release_children": {
@@ -17764,11 +17893,8 @@ function createLegionTool(deps) {
17764
17893
  return jsonSuccess({});
17765
17894
  }
17766
17895
  case "sign_off": {
17767
- const grantId = await mintClaimGrant(client, active);
17768
- await client.signOff({
17769
- grantId,
17770
- issue: requiredString2(parameters, operation2, "issue")
17771
- });
17896
+ const issue2 = requiredString2(parameters, operation2, "issue");
17897
+ await retryWhileDaemonRestarts("sign_off", async () => client.signOff({ grantId: await mintClaimGrant(client, active), issue: issue2 }), restart);
17772
17898
  return jsonSuccess({});
17773
17899
  }
17774
17900
  case "close_root": {
@@ -13,7 +13,7 @@ You own one issue tree from its first decision to the merge that closes it. The
13
13
 
14
14
  - Act on delivered wakes and the live artifacts they name; build no wait loop, since every wake reaches you.
15
15
  - Check each claim against the code as fetched now (the default branch your addressing line names, or the head under review), never the workspace as it was at admission, and tell every fresh reader you dispatch to do the same: the branch may have moved since.
16
- - Before you end a turn that waits on a human, open what they must answer: a decision block, a `dispatch ask` or `dispatch request-approval`. A turn that ends with nothing open asks nobody, and the tree stops.
16
+ - Before you end a turn that waits on a human, open what they must answer: a decision block, a `dispatch ask` or `dispatch request-approval`. A turn that ends with nothing open asks nobody, and the tree stops. Open an ordinary ask on the root issue itself, never a child: only a question a session of this tree opens on the root starts a wait on a person, so an ask on a child leaves the root holding its slot while nothing runs. Once you have handed a root question to the human, end your turn: when nothing else holds the slot — no other issue of the tree in a phase, the `legion` label still on, and no person or outside session holding the root's Dispatch claim — the root returns its admission slot (`awaiting_person`), the daemon suspends this session at the end of the turn, and the human's answer resumes it with a task naming what answered and what is still with them. The same holds once you request approval and `register_gate` that version: the registration hands the spec to the human, so end your turn there too, and the human's review, or a newer version once Dispatch shows the request back on your turn, resumes you.
17
17
  - Deliver every criterion of the root spec in this tree. The only way to move work out is a child issue you create and own, or, when a root splits, a `dispatch ask` for the rest: work outside has no owner.
18
18
  - A problem outside the tree is a `dispatch ask`, never an issue you file, because a person decides whether it needs one (`skill://legion-worker/references/findings.md`). A clear defect is the exception. Fix one in code this tree touches or depends on in this tree's pull request. Send any other to the controller, which gives it to the tree that owns that code.
19
19
  - Before you write a spec or ask a person anything, read the repository's `STRATEGY.md` when it has one, then use a standard best-practice solution; ask only what neither settles, with one recommendation. A design that crosses a Boundary in that file goes to its owner before it is built. Find the standard with a web search and with how two or three well-regarded projects solve the problem. Cite them in a few lines of the spec's `Design`. A design far from the standard is a sign to fix the underlying problem, not to add a workaround.
@@ -29,22 +29,25 @@ You own one issue tree from its first decision to the merge that closes it. The
29
29
  - Every child names the running system its criteria are proven on (the command, service or page the tester drives) and the repository skill that drives it. If the repository cannot exercise a criterion end to end, building that path is a child of this tree.
30
30
  - The issue's primary document is the root spec: extend it in place, because a second "spec" artifact replaces the human's document. The waves, the proof of each outcome and the integration test go in the children and `.legion/<issue>/plan.json`, not the root spec.
31
31
  - Read `skill://legion-architect/references/design-gate.md` before you write the root spec and before any edit to it after approval, because any new version closes the gate. It also says which gate runs, and that a sub-architect runs none.
32
- - A root architect then ends its turn and releases no wave until a delivered wake shows `design-approved`: the daemon starts no phase in the tree before it. A sub-architect waits for none and releases its children with `release_children`.
32
+ - A root architect then ends its turn and releases no wave until a delivered wake shows `design-approved`: the daemon starts no phase in the tree before it. A root that waited on a person's answer gets no `design-approved` notice: a resume task saying "this resume is your design-approved wake" is that wake. A sub-architect waits for none and releases its children with `release_children`.
33
33
 
34
34
  ## 2. Children in flight
35
35
 
36
36
  - Set a child's dependencies in one call naming every issue it waits on: `dispatch issue-update --issue <child> --blocked-by <a> --blocked-by <b>`, or `--blocked-by` on `dispatch issue` at creation. The call replaces the child's whole list, so read its existing links first (`dispatch read`). Release it with the rest: `legion({ op: "release_children", issues: ["LEGION-41", "LEGION-42"] })`. Legion holds a released child that waits on an issue not `done` and starts it itself at its last blocker's close. A child that builds on another repository's merged change is not held this way: release it as `skill://legion-architect/references/decomposition.md` "Across repositories" says.
37
- - On each `child-closed`, read the child's handoff. Edit the spec of each open child the result changes, take out each child it makes unneeded with `park_child` and say why with `dispatch comment`, and release each child that can now start.
37
+ - Release every child as soon as it is filed, a later wave's included, each with `--blocked-by` the children it builds on, and never hold a release back for a close: a released child, held or running, keeps the root's close waiting, but once the root's own pull request has merged the root closes the moment its last released child leaves, and a child released after that finds no live tree and runs as an orphan root of its own.
38
+ - On each `child-closed`, read the child's handoff. Edit the spec of each open child the result changes, and take out each child it makes unneeded with `park_child` and say why with `dispatch comment`.
38
39
  - Before you park a child for good (one made unneeded, or parked so its tree can finish), set the `--blocked-by` list of each sibling that waits on it again without it (one call with the rest of its list, or `--clear-blocked-by` when it was the only one), or park that sibling too: a parked child is stopped, not done, and its dependents stay held with no notice. A park followed by `rerun_child` keeps its dependents' links: they wait through the rerun and start when the rerun's merge closes the child.
39
40
  - A released child runs in your tree's admission slot, never its own, and never becomes a root while your tree is live.
41
+ - A root that waited in the admission queue, never ran and never left another tree joins your tree once a person makes it your child, if it is `todo`, and otherwise at your release of it (the `child-status` row of [wake routing](skill://legion-architect/references/wake-routing.md)); once in, it is held for its blockers like any child you release. A root that left another tree, handed over by its finished tree or admitted as an orphan, stays its own tree and never joins yours, even one that was your root's child before your root was admitted again. A labelled `todo` issue under a child you have not released, unless it ran as a tree of its own or left another tree, is held under that unreleased child (not a blocked-by hold): it waits for no slot until your release of it or of that child brings it in, with any `todo` child of its own.
42
+ - A release that cannot take a child in is refused 409 and writes nothing, its message naming the child and why: release the rest of the wave again without it. `CHILD_NOT_ADOPTABLE` is an issue that runs or ran as a tree of its own, carries a run handed over to it, or was handed over (or admitted as an orphan) as a root of its own: leave it there, or ask the controller. `CHILD_IN_OTHER_TREE` is another tree's child. When the tree it names is a root held under an unreleased child of yours (a root that queued and never ran, so it has no architect of its own), release that root, or the unreleased child it sits under, instead: its children come with it. Otherwise re-parent the child back in Dispatch, or ask that tree's architect or the controller.
40
43
 
41
44
  ## 3. Children complete
42
45
 
43
- No notice marks the last child's close. The root can reach `accepting` before any child merges, but its `sign_off` waits for them (section 6).
46
+ No notice marks the last child's close. The root can reach `accepting` before any child merges; accept its own pull request then (section 6), and its close waits for the rest of the tree.
44
47
 
45
48
  ## 4. Integration verification
46
49
 
47
- - File a final integration child whose acceptance is every parent criterion proven on the default branch your addressing line names (never a hard-coded `main`), in each repository a criterion spans. Release it only after every other child has closed, because it must test a branch that carries their merges; accept the root only after it closes.
50
+ - File a final integration child whose acceptance is every parent criterion proven on the default branch your addressing line names (never a hard-coded `main`), in each repository a criterion spans. Release it at once with `--blocked-by` naming every other child: Legion holds it until the last of them closes and starts it then, so it tests a branch that carries their merges. The root's close waits for it, so the root is done only once it closes. Each child you add later joins its `--blocked-by` list in the same turn it is filed (section 2).
48
51
  - Read its tester evidence, not a child pull request's check status. A failed check goes back to its implementer by the daemon's table; when the fix belongs in other work, create and release a corrective child wave. Never downgrade a parent criterion or carry the failure forward.
49
52
 
50
53
  ## 5. Retro
@@ -60,9 +63,9 @@ The reviewer's `phase-finished` with an `APPROVED` review puts the issue in `acc
60
63
  - the reviewer's approval of that head by SHA (`gh api --paginate repos/<o>/<r>/pulls/<n>/reviews`);
61
64
  - each of the reviewer's prose findings, checked against the code at that head and command output, not against the reviewer's own verdict: no other agent has read that prose.
62
65
 
63
- Accept only when each acceptance line has passing evidence at the approved head, every corrective child and (for a root) the integration child has closed, and no criterion left the tree. A finding ask on the issue holds the acceptance until it is resolved or carries the controller's reply (`skill://legion-architect/references/acceptance.md`). Post that evidence as a `dispatch comment`, then `legion({ op: "sign_off", issue: "<KEY>" })`.
66
+ Accept only when each acceptance line of the issue's own pull request has passing evidence at the approved head and no criterion left the tree; a criterion only the tree as a whole meets is the integration child's (section 4), not a reason to hold the root's own pull request. A finding ask on the issue holds the acceptance until it is resolved or carries the controller's reply (`skill://legion-architect/references/acceptance.md`). Post that evidence as a `dispatch comment`, then `legion({ op: "sign_off", issue: "<KEY>" })`.
64
67
 
65
- - `sign_off` works only in `accepting` (`SIGNOFF_OUTSIDE_ACCEPTING`) and is refused 409 `SIGNOFF_CHILD_IN_WORKFLOW` while any issue beneath this one is in the workflow, naming each: wait for the last, or take one out with `park_child`.
68
+ - `sign_off` works only in `accepting` (`SIGNOFF_OUTSIDE_ACCEPTING`). Children still in the workflow do not hold it: a root whose pull request merges waits at its close for the rest of its tree.
66
69
  - When an acceptance check fails: `request_backward_move` with `issue`, `to` (`implementing` or `testing`) and a `reason` naming the failure. It counts a review round.
67
70
  - On every end-game wake (`phase-finished`, `catch-up`, `checks-red`, `review-stuck`), read `gh pr view <n> --json mergeable,mergeStateStatus`, because a `CONFLICTING` pull request gets no CI and no wake. Then, and after acceptance, follow `skill://legion-architect/references/acceptance.md`.
68
71
 
@@ -26,8 +26,13 @@ legion({ op: "sign_off", issue: "LEGION-40" })
26
26
  ```
27
27
 
28
28
  - The issue's Dispatch status stays `needs_review` in `accepting`; `sign_off` moves it to `retro`.
29
- - Only what is beneath the issue counts for `SIGNOFF_CHILD_IN_WORKFLOW`: a child with nothing
30
- beneath it is accepted while its own root runs.
29
+ - A `catch-up` whose `reason` names an issue as waiting for your acceptance is the wake for it: it
30
+ is in `accepting`, and the `phase-finished` notice that moved it there may never have reached you
31
+ (the daemon drops a notice to a failed architect, so a retried one learns of the wait from its
32
+ catch-up alone). Accept it as above.
33
+ - Nothing beneath the issue holds the acceptance: accept a parent's own reviewed head while its
34
+ children still run. A root's close waits for them instead (*The close*, below); a parent that is
35
+ not the root closes at its own merge, and its children carry on under the root.
31
36
  - `request_backward_move` names the issue, because your grant serves the tree; the daemon starts
32
37
  that phase's role with your reason in its task.
33
38
  - `handoff_complete` from you in `accepting` is refused `ACCEPTANCE_IS_SIGN_OFF`. CI red at the head
@@ -34,8 +34,9 @@ EOF
34
34
  - `--project` is the issue key's prefix before `-<n>` (`LEGSMOKE-3` → `LEGSMOKE`).
35
35
  - In a root session `$LEGION_ISSUE` is the root (it equals `$LEGION_TREE`): pass it as `--parent`.
36
36
  The daemon makes the sub-issue link from `--parent`.
37
- - Group the returned keys into ordered waves: you release each wave by key with
38
- `release_children`, and a child is inert until then.
37
+ - Order the returned keys into waves with `--blocked-by` links (a later wave's child blocked by the
38
+ children it builds on), then release every child at once with `release_children`: Legion holds a
39
+ released child until its last blocker closes, and a child is inert only until it is released.
39
40
  - Do not pass `--assignee`, because the default keeps the tree's questions in one Inbox: under the
40
41
  shared token a child inherits its parent's assignee (the human who answers the tree's asks), else
41
42
  the project's default assignee, which a person sets on the Settings page; under a personal token
@@ -95,7 +95,15 @@ Facts about the request:
95
95
  never wrote is no registration: the gate is as it was. Read the document's latest version, check
96
96
  it is the spec the human is asked to approve, and `register_gate` that version.
97
97
 
98
- Then end your turn. Release no wave until a delivered wake shows `design-approved` on the root. On
98
+ Then end your turn. The registration hands the spec to the human: when nothing else holds the
99
+ slot, the root returns its admission slot and waits on its design gate (`awaiting_person`), as a
100
+ root question handed to the human does ("Every turn" in `skill://legion-architect/SKILL.md`). The
101
+ human's approval or changes request resumes you, and so does a newer spec version once Dispatch
102
+ shows the request back on your turn. While the root waits on a question instead, the human's
103
+ approval or changes request of the spec answers that wait too, and the resume task says how the
104
+ spec stands. Release no wave until a delivered wake shows `design-approved` on the root, or a
105
+ resume task says its design gate is open: the daemon sends a root waiting on a person no
106
+ `design-approved` notice, and "this resume is your design-approved wake" is that wake. On
99
107
  `design-changes-requested`: revise the spec as the human's reason asks, run step 3's two reviews on
100
108
  the new version, resolve every finding, request approval again and end your turn. A
101
109
  `design-changes-requested` may name a version below the latest, or arrive after another for the
@@ -19,14 +19,14 @@ and 7 (a conflict, the hand-over, the close) is in
19
19
  | Wake | Procedure |
20
20
  | --- | --- |
21
21
  | `carried-gate-opened` | Only a root whose catch-up said `carried` gets it. Its `version` and `reason`: a human approved the old root's spec document at `version`, which opened the gate this root carried; the daemon has released any READY it refused and started any child waiting on it. It is not `design-approved`: do not proceed to section 2 or release a wave because of it, and start nothing. |
22
- | `child-status` | A child of your tree left the workflow or re-entered it; the reason names the child and its new status. `todo` (a human's move, your `release_children`, or your `rerun_child`) means it runs again under your tree from planning, and the daemon starts it; you start nothing. A child held for a blocker starts the same way once nothing holds it, and the reason, naming it and its root, says what cleared the hold: its last blocker's close, naming the issues it waited on, now done, or a person or you taking its blocked-by links off; you start nothing for it either. `backlog`, `icebox` or `triage` (a human's move, or your `park_child`) means the daemon has suspended its workers, and it advances no further until it is set back to `todo`. What the rest of the tree does is your decision. |
23
- | `child-closed` | Read the child completion and remaining open children. Re-scope open work, park obsolete work for good with `park_child` (first take its link off each sibling that waits on it, or park that sibling too, as section 2 says), and release an appropriate next wave with `release_children`. When a worker waits on this child for a missing surface (see `phase-finished`), tell it to continue with `envoy_publish` to its role topic. The last child's close is not the end-game (section 3). |
22
+ | `child-status` | A child of your tree left the workflow or re-entered it, or joined your tree from the admission queue; the reason names the child and its new status, or reads `<KEY> joined the tree of <ROOT> from the admission queue`. `todo` (a human's move, your `release_children`, or your `rerun_child`) means it runs again under your tree from planning, and the daemon starts it; you start nothing. A child held for a blocker starts the same way once nothing holds it, and the reason, naming it and its root, says what cleared the hold: its last blocker's close, naming the issues it waited on, now done, or a person or you taking its blocked-by links off; you start nothing for it either. `backlog`, `icebox` or `triage` (a human's move, or your `park_child`) means the daemon has suspended its workers, and it advances no further until it is set back to `todo`. Joined from the admission queue means a root that waited for a slot and never ran, which a person made a child of your tree or your `release_children` adopted, now runs in your tree with no slot or architect of its own, from planning when it is `todo` and parked otherwise; one that waits on an unfinished blocked-by issue is held for that blocker like any other child, and starts when nothing holds it; you start nothing. What the rest of the tree does is your decision. |
23
+ | `child-closed` | Read the child completion and remaining open children. Re-scope open work, park obsolete work for good with `park_child` (first take its link off each sibling that waits on it, or park that sibling too, as section 2 says), and file any new child the result calls for and release it at once with `release_children`, blocked by what it builds on (section 2). When a worker waits on this child for a missing surface (see `phase-finished`), tell it to continue with `envoy_publish` to its role topic. The last child's close is not the end-game (section 3). |
24
24
  | `child-release-stalled` | Its `reason` names a child your `release_children` released while Dispatch already showed it `todo`, and that child's parent, whose release has not reached the daemon after three tries. Read the parent's record with the `legion` tool's `read_record` (`issue` the parent); run `release_children` for the child again once the parent is recorded in your tree, and tell the controller if it never is. |
25
25
  | `child-not-recorded` | Its `reason` names a child your tree did not record and why: its repository resolves to none (neither it nor an ancestor names one, and the project has no default), or to one not mapped to the project, or to one the daemon leaves out for a missing GitHub App. The daemon said why on the child too. Set the child's repository to one of the project's (`dispatch issue-update --repo owner/name`), then release it again with `release_children`; tell the controller when the repository it needs is not served. |
26
- | `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
- | `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. A `version` below the latest, or a second request for one version, is a request that reached the daemon late: read it as the same section's last paragraph says. Each request arrives once. |
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. A root that was waiting on a question in `awaiting_person` gets no such notice: the task that resumes it says "Your spec is approved at version N and the design gate is open: this resume is your design-approved wake", and that resume is this wake — proceed to section 2 on it. |
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. On a root that was waiting on a question in `awaiting_person`, a review the human gave after the wait began comes in the resume task, not in this notice, whether it answered the wait or came after something else did: the task names the review with its version and reason, unless a later approval superseded it. A `version` below the latest, or a second request for one version, is a request that reached the daemon late: read it as the same section's last paragraph says. Each request arrives once. |
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 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 so is a wording nit or any clear fix in a file it touches, so the `Fast-follow:` line carries only cleanup in files this pull request does not touch. **Split**: part of the work cannot settle in this pull request. 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 -`. Release it now with `release_children`: Legion holds it on that `--blocked-by` link until this issue closes, and starts it then. On the root, narrow it to what settles here and open a `dispatch ask` for the rest (`skill://legion-worker/references/findings.md`): a person decides whether that becomes an issue. Tell the implementer and the reviewer that this pull request no longer carries it. **Accept the rounds**: each round found less than the one before it, and the last found no defect in the change itself (a stale line, a comment that states an old default); let the loop run and say why in the comment. A later notice is a new decision: accept again only when its round found less than the round you last accepted, and otherwise narrow or split. A finding that changes what the spec 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/<issue>/` — handoff-only pushes (`.legion/<issue>/` 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/<issue>/`, 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 so is a wording nit or any clear fix in a file it touches, so the `Fast-follow:` line carries only cleanup in files this pull request does not touch. **Split**: part of the work cannot settle in this pull request. On a child, file that part as a child of this issue's parent, never of this one (this issue closes at its own merge, and the split part usually builds on this pull request's unmerged code): `dispatch issue --project <project> --parent <parent> --title '<outcome>' --blocked-by <this issue> --spec-file -`. Release it now with `release_children`: Legion holds it on that `--blocked-by` link until this issue closes, and starts it then. On the root, narrow it to what settles here and open a `dispatch ask` for the rest (`skill://legion-worker/references/findings.md`): a person decides whether that becomes an issue. Tell the implementer and the reviewer that this pull request no longer carries it. **Accept the rounds**: each round found less than the one before it, and the last found no defect in the change itself (a stale line, a comment that states an old default); let the loop run and say why in the comment. A later notice is a new decision: accept again only when its round found less than the round you last accepted, and otherwise narrow or split. A finding that changes what the spec 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`). |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: legion-controller
3
- description: Use when you are a Legion project's controller session, on any controller wake (triage, slot-free, todo, tick, unslotted, held, worker-died), weekly-plan ranking, backlog stewardship, a clear defect or finding ask a role sends, an issue a person asks you to file, the daily report, an architect's escalation, or a person's message or mention.
3
+ description: Use when you are a Legion project's controller session, on any controller wake (triage, slot-free, todo, tick, unslotted, awaiting-person, held, worker-died), weekly-plan ranking, backlog stewardship, a clear defect or finding ask a role sends, an issue a person asks you to file, the daily report, an architect's escalation, or a person's message or mention.
4
4
  ---
5
5
 
6
6
  # Legion Controller
@@ -43,7 +43,9 @@ because Envoy keeps no copy of a wake sent while no controller ran.
43
43
  1. Read `read_state`. Handle each issue whose `phase` is `held` with `holdReason: escalated`, and
44
44
  each root whose `architect.state` is `failed` and whose `phase` is not `done`, as its wake says.
45
45
  Name in your summary each root at `phase: done` whose `status` is `in_progress`, `testing`,
46
- `needs_review` or `retro`: someone else works it, as the `unslotted` wake reports.
46
+ `needs_review` or `retro`: Legion runs it no longer, as the `unslotted` wake reports: someone
47
+ else moved it there while it waited for a slot, or it waited on a person (`awaiting_person`) and
48
+ lost its label or its Dispatch claim, which leaves the `in_progress` it ran with.
47
49
  2. Triage the roots handed to Legion while you were away, as
48
50
  `skill://legion-controller/references/triage.md` "At every start" says.
49
51
  3. Catch up on the report issue
@@ -59,6 +61,7 @@ Wake details: `skill://legion-controller/references/wakes.md`.
59
61
  | `slot-free on <KEY>`, `todo on <KEY>` | Name the free slots; take nothing |
60
62
  | `tick on <PROJECT>` | The every-turn steps |
61
63
  | `unslotted on <KEY>` | Re-admit nothing; name it in your summary |
64
+ | `awaiting-person on <KEY>` | Nothing: do not park or re-admit it, and take nothing for the slot it returned |
62
65
  | `held on <KEY>` | Nothing, unless `reason: "escalated"`: then an escalation |
63
66
  | `worker-died on <KEY>` (`role: "architect"`) | Re-admit once its cause is gone, else park it |
64
67
  | An architect's escalation | `skill://legion-controller/references/escalation.md` |
@@ -7,14 +7,16 @@ waits, or neither. Read its key against `admission` and its tree's root.
7
7
  | What `read_state` shows | What it means | What you do |
8
8
  |---|---|---|
9
9
  | The key is in `admission.active` | A root holding a slot: its tree runs, `admitted` until its architect starts the first phase | Nothing, beyond [trees waiting on a root claim](skill://legion-controller/SKILL.md#trees-waiting-on-a-root-claim) |
10
- | The key is in `admission.waiting` and not in `admission.blocked` | A root handed to Legion in `todo`, queued in Dispatch rank order for the next free slot | Nothing: it takes the next free slot by itself |
10
+ | The key is in `admission.waiting` and not in `admission.blocked` | A root handed to Legion in `todo`, queued for the next free slot: a root a person answered while it waited in `awaiting_person` comes first, oldest answer first, then every root that never ran, in Dispatch rank order. A root that left another tree (handed over by its finished tree, or admitted as an orphan) waits here as its own root even under a live tree, the old root's return included: no tree adopts it | Nothing: it takes the next free slot by itself |
11
+ | `phase: awaiting_person`, `waitingOn` | A root that already ran and now waits only on a person: an answer to its architect's question (`waitingOn.kind` `ask`) or a review of its spec (`gate`). It holds no slot, its architect session is suspended, and its tree stays live. Unanswered, it is in neither list; `waitingOn.answeredAt` means the daemon has promoted it to the front of `admission.waiting` | Nothing: it is not something to admit, label or park, and the slot it returned is no reason to take an issue ([Free slots](skill://legion-controller/SKILL.md#free-slots)). The daemon resumes it in its own architect session at the next free slot. If its label goes or a person or outside session claims it on Dispatch, the daemon sends `unslotted` and does not resume it |
11
12
  | The key is in `admission.waiting` and in `admission.blocked` | A root in the line that waits on the Dispatch issues `admission.blocked` names: admission passes it over, and it takes no slot, until every one of them is `done` | Nothing: the wait ends when each blocker reaches `done` (a person's close, or the blocker's own merge) or its link is taken off (by a person or the tree's architect). Name the wait in the day's report |
12
- | `phase: admitted`, in neither list, and Dispatch shows a `child_of` parent | A child: it runs or waits with its tree, the root its `child_of` chain reaches. Under a root whose `phase` is `done` it waits with it | Leave it to its tree. It runs again when its root does, or as a root of its own once it is `todo` with the `legion` label |
13
+ | `phase: admitted`, in neither list, and Dispatch shows a `child_of` parent | A child: it runs or waits with its tree, the root its `child_of` chain reaches. Under a root whose `phase` is `done` it waits with it. A labelled `todo` issue here is not in the line either: one whose recorded parent is in a live tree runs in that tree (the daemon adopted it there), or is held with that parent while the parent itself is held under an unreleased child, and one whose parent is not recorded but sits under a live tree is held under an unreleased child of that tree until its architect releases it or its parent. That hold is not `admission.blocked`, which names only blocked-by links | Leave it to its tree. It runs again when its root does, or as a root of its own once it is `todo` with the `legion` label and no live tree is above it |
13
14
  | `phase: admitted`, `status: todo`, in neither list, no parent | A root a person took out of the line by taking its label off | Nothing: the label puts it back in the line, and a person's label is their decision |
14
- | `phase: done` while `status` is `in_progress`, `testing`, `needs_review` or `retro` | Someone other than Legion moved it to a running tree's status while Legion was not running it. A root that waited for a slot is taken out of the workflow at that move, since no slot would ever reach it, and the daemon sends the `unslotted` wake; a parked child stays parked. Legion starts nothing for either | Re-admit nothing yourself: `dispatch read` shows who works it now. Name it in your summary; `todo` with the `legion` label queues it again |
15
+ | `phase: done` while `status` is `in_progress`, `testing`, `needs_review` or `retro` | Legion is not running it under that status, and it sent the `unslotted` wake. Either someone other than Legion moved a root that waited for a slot there, and the daemon took it out of the workflow at that move since no slot would ever reach it; or a root waiting on a person could not be resumed — its `legion` label is gone, or a person or an outside session took its Dispatch claim — and the daemon took it out the same way, leaving the `in_progress` it ran with. A parked child stays parked. Legion starts nothing for any of them | Re-admit nothing yourself: `dispatch read` shows who works it now. Name it in your summary; `todo` with the `legion` label queues it again |
15
16
 
16
- A root in the workflow with no slot that is in neither `admission.waiting` nor the label-removed
17
- row above does not stay that way: every pass of admission (a Dispatch event, the daemon's boot
18
- read, its tick) takes it out of the workflow and sends the `unslotted` wake. The wake is live
19
- only, so a controller that starts later finds such a root by the last row, as its start procedure
20
- does.
17
+ A root in the workflow with no slot that is in neither `admission.waiting`, nor the label-removed
18
+ row above, nor `todo` under a live tree does not stay that way: every pass of admission (a Dispatch
19
+ event, the daemon's boot read, its tick) takes it out of the workflow and sends the `unslotted`
20
+ wake — an unanswered `awaiting_person` root only when its label or its Dispatch claim makes a
21
+ resume unsafe. The wake is live only, so a controller that starts later finds such a root by the
22
+ last row, as its start procedure does.
@@ -16,8 +16,9 @@ the issue header. Triage it as `skill://legion-controller/references/triage.md`
16
16
 
17
17
  ## `slot-free on <KEY>` (`{kind: "slot-free"}`)
18
18
 
19
- The daemon released `<KEY>`'s slot, because its tree finished or left the workflow, and no waiting
20
- root took it. Name the free slots; take nothing.
19
+ The daemon released `<KEY>`'s slot and no waiting root took it: its tree finished or left the
20
+ workflow, or the root returned the slot to wait on a person (`awaiting_person`, which also sends
21
+ its own `awaiting-person` wake). Name the free slots; take nothing.
21
22
 
22
23
  ## `todo on <KEY>` (`{kind: "todo"}`)
23
24
 
@@ -50,12 +51,25 @@ take nothing for them.
50
51
 
51
52
  ## `unslotted on <KEY>` (`{kind: "unslotted", reason}`)
52
53
 
53
- A root that waited for a slot was moved from `todo` to the status `reason` names, by a person or by
54
- another session, so the daemon took it out of the workflow (`phase: done`) and starts nothing for
55
- it. Re-admit nothing, because the move was someone else's; `dispatch read` shows who claims it now.
56
- Tell the person in your summary: "<KEY> left Legion's waiting line: it was set to <status> while it
57
- waited for a slot, so Legion let it go; `todo` with the `legion` label queues it again". Other
58
- admission states: `skill://legion-controller/references/admission.md`.
54
+ Either a root that waited for a slot was moved from `todo` to the status `reason` names, by a
55
+ person or by another session, or a root waiting on a person (`awaiting_person`) could not be
56
+ resumed — its `legion` label is gone, or a person or a session outside its tree took its Dispatch
57
+ claim. Either way the daemon took it out of the workflow (`phase: done`) and starts nothing for it;
58
+ `reason` says which. Re-admit nothing, because the move was someone else's; `dispatch read` shows
59
+ who claims it now. Tell the person in your summary: "<KEY> left Legion's workflow: <reason as the
60
+ wake gives it>; `todo` with the `legion` label queues it again". Other admission states:
61
+ `skill://legion-controller/references/admission.md`.
62
+
63
+ ## `awaiting-person on <KEY>` (`{kind: "awaiting-person", reason}`)
64
+
65
+ A root whose architect asked a person a question, or handed its spec to a person for approval, and
66
+ now waits only on them: the daemon returned
67
+ its slot and suspends the architect after its turn, keeping its session. Read
68
+ `issues.<KEY>.waitingOn`. Do not park it or start anything, and take nothing for the slot it
69
+ returned: it is no reason to label or admit an issue ([Free
70
+ slots](skill://legion-controller/SKILL.md#free-slots)). The person's answer puts it at the front of
71
+ `admission.waiting` by itself, and the next free slot resumes its architect. A lost label or a
72
+ foreign Dispatch claim sends the separate `unslotted` wake.
59
73
 
60
74
  ## `held on <KEY>` (`{kind: "held", phase, role?, reason?}`)
61
75
 
@@ -19,7 +19,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
19
19
 
20
20
  ## Stop rules
21
21
 
22
- - **A refused completion is information, not a retry loop.** Quote the answer verbatim to the architect, so "my work is lost" and "my work belongs to an ended run" read differently. `HANDOFF_STALE_GENERATION` (the issue is on a newer run, or your turn began before the daemon recorded your task, which it then sends again) or `HANDOFF_NO_RUN` (your claim holds no task): push nothing further and tell the architect; when a task then arrives whose work is already committed, call `handoff_complete` again, and never redo the work or write a second handoff. `HANDOFF_NOT_CURRENT_PHASE`: the issue left your phase; report, never complete again. `HANDOFF_ALREADY_RECORDED`: the first answer stands, an acceptance or one of the four refusals the daemon records (`HANDOFF_STALE_GENERATION`, `HANDOFF_NOT_CURRENT_PHASE`, `READY_REQUIRED`, `HANDOFF_NOT_NEW`); if you did not see it, tell the architect. A refusal given before any fact is applied (READY's checks of the head among them) records nothing, so the corrected call at the same commit is applied. A reminder is not a task: after an accepted completion, a second call is refused.
22
+ - **A refused completion is information, not a retry loop.** Quote the answer verbatim to the architect, so "my work is lost" and "my work belongs to an ended run" read differently. `HANDOFF_STALE_GENERATION` (the issue is on a newer run: your turn began before the daemon's task for that run reached you, and the answer names that task when it is queued) or `HANDOFF_NO_RUN` (your claim holds no task): push nothing further, end the turn without completing, and tell the architect; if the task then arrives (none may: the new run's phase can be another claim's) and its work is already committed, call `handoff_complete` again at the same commit in that task's turn, and never redo the work or write a second handoff. `HANDOFF_NOT_CURRENT_PHASE`: the issue left your phase; report, never complete again. `HANDOFF_ALREADY_RECORDED`: the first answer stands, an acceptance or one of the refusals the daemon records (`HANDOFF_NOT_CURRENT_PHASE`, `READY_REQUIRED`, `HANDOFF_NOT_NEW`, and `HANDOFF_STALE_GENERATION` only when the issue moved during the call); if you did not see it, tell the architect. A refusal given before any fact is applied (`HANDOFF_STALE_GENERATION`, `HANDOFF_NO_RUN` and READY's checks of the head among them) records nothing, so the corrected call at the same commit is applied. A reminder is not a task: after an accepted completion, a second call is refused.
23
23
  - **After your phase completes, the workspace is read-only for you**: no commit, no `jj new`, no edit to a tracked file, in any later turn until a new task names your phase. The active phase owns it, and you stay live only to answer questions.
24
24
  - **Never start another Legion role**: the daemon starts every phase worker.
25
25
  - **Never end your turn blocked on a decision someone else owns.** Send a product, scope, design, lifecycle or cross-phase decision to the owning architect as your role prompt says, with the issue, phase, verified observation, what you tried and the decision needed; a human-only to-do is a `dispatch ask`. Otherwise proceed: a phase that stops silently holds its issue until someone notices.
@@ -46,7 +46,7 @@ Read each by its `skill://` link: a read by path stops at 300 lines.
46
46
  ## Every role pushes its own commits
47
47
 
48
48
  - Before a push, read `jj -R "$LEGION_WORKSPACE" log -r 'ancestors(@, 5)'` (earlier phases' commits in it are expected) and run the identity check (`skill://legion-worker/references/default-branch-check.md`). Add no attribution trailer: the pane's jj adds `Omp-Session:`.
49
- - A push whose commits touch only `.legion/` ends its head's message with `skip-checks: true`, unless it is retro's push (the implementer's retro phase, before the merger's). Retro's push never carries the line, whatever paths it touches, because it is the last push before the merge and a base that requires a check will not merge a head whose checks never ran. No other push carries it. The daemon classifies a push by its paths, never by the trailer, so a wrong trailer can stall the issue but never makes it pass.
49
+ - A push whose commits touch only `.legion/<issue>/` ends its head's message with `skip-checks: true`, unless it is retro's push (the implementer's retro phase, before the merger's). Retro's push never carries the line, whatever paths it touches, because it is the last push before the merge and a base that requires a check will not merge a head whose checks never ran. No other push carries it. The daemon classifies a push by its paths, never by the trailer, so a wrong trailer can stall the issue but never makes it pass.
50
50
  - A planner whose issue moved back from implementing starts on the remote tip, `jj -R "$LEGION_WORKSPACE" new legion/<KEY>@origin`, so its handoff never sits on the implementer's unpushed commits.
51
51
 
52
52
  ## PR body, review, and the merge gate
@@ -23,10 +23,10 @@ it, and any other change to the head voids it.
23
23
  `task(agent="thermonuclear-code-quality")` once at that head — the head the round reviews —
24
24
  and records the verdict. The CI it reads is the checks that stand for the code under review:
25
25
  those GitHub ran for the newest push of the branch that started `pull_request` workflows, when
26
- every commit above that push's head changes only `.legion/` (GitHub starts no workflow run for a
27
- head whose commit message carries `skip-checks: true`, which every handoff-only push before
28
- retro does; retro's push carries no such line, so its head does get GitHub's workflow runs of
29
- its own). With `<o>/<r>`, `<n>`, `<base>`, `<headRefName>` and `<headRefOid>` from
26
+ every commit above that push's head changes only `.legion/<issue>/` (GitHub starts no workflow
27
+ run for a head whose commit message carries `skip-checks: true`, which every handoff-only push
28
+ before retro does; retro's push carries no such line, so its head does get GitHub's workflow runs
29
+ of its own). With `<o>/<r>`, `<n>`, `<base>`, `<headRefName>` and `<headRefOid>` from
30
30
  `gh pr view <n> --json baseRefName,headRefOid,headRefName`, four reads. The required set is the
31
31
  base branch's rulesets' required checks and its branch protection's, when it has any. Both
32
32
  branch endpoints take the base as a URL path, each segment escaped and each `/` kept (a raw `#`
@@ -46,8 +46,9 @@ it, and any other change to the head voids it.
46
46
  newest first). That commit's check-runs
47
47
  (`gh api repos/<o>/<r>/commits/<ci head>/check-runs --paginate --jq '.check_runs[] | [.name, .status, .conclusion] | @tsv'`):
48
48
  every required name present, `completed` and `success` (GitHub counts `neutral` and `skipped`
49
- as passing for a required check). And the compare proving only `.legion/` changed since
50
- (`gh api repos/<o>/<r>/compare/<ci head>...<headRefOid> --jq '{handoffsOnly: ([.files[].filename] | all(startswith(".legion/"))), files: (.files | length)}'`
49
+ as passing for a required check). And the compare proving only `.legion/<issue>/` changed since,
50
+ `<issue>` being your `LEGION_ISSUE` (another issue's `.legion/` is code to the daemon and READY)
51
+ (`gh api repos/<o>/<r>/compare/<ci head>...<headRefOid> --jq '{handoffsOnly: ([.files[] | .filename, (.previous_filename // empty)] | all(startswith(".legion/<issue>/"))), files: (.files | length)}'`
51
52
  prints `handoffsOnly` `true`; `false` means code was pushed since CI ran, so wait for the new
52
53
  push's runs and read the CI head again). GitHub's compare lists at most 300 files for the whole
53
54
  comparison, so `files` at 300 is a comparison GitHub cut at its cap and `handoffsOnly` decides
@@ -98,7 +99,7 @@ it, and any other change to the head voids it.
98
99
  `REQUEST_CHANGES` when the code is at fault, and your own commit to fix when your prose broke
99
100
  it. The architect reads your prose commits at its acceptance (*The architect's acceptance*,
100
101
  below), and that read is no new round. A round after a READY refusal is the one exception. Its
101
- approval needs the tester's `E2E` line to name the head with only `.legion/` changed since. So
102
+ approval needs the tester's `E2E` line to name the head with only `.legion/<issue>/` changed since. So
102
103
  there you fix no prose yourself, and you name each prose finding in a `REQUEST_CHANGES`.
103
104
 
104
105
  A reviewer's phase ends with its completion, not with its review. Every round writes a handoff
@@ -109,7 +110,7 @@ whatever CI says and the issue leaves reviewing with it. The approval waits on n
109
110
  handoff-only commit either: it ends the round whatever CI says there, since the round reads no CI
110
111
  verdict, and a red the daemon reads later — at that commit or any above it — is the code's red
111
112
  and resumes the implementer from wherever the issue is (reviewing, retro or merging) without the
112
- reviewer. A head your own push changed beyond `.legion/` is the exception. The daemon marks it a
113
+ reviewer. A head your own push changed beyond `.legion/<issue>/` is the exception. The daemon marks it a
113
114
  planned red and sends nothing back for it, so wait for its checks yourself (*Fix a prose-only
114
115
  finding yourself*, above). A review of a head the handoff push then replaces names a head the pull
115
116
  request no longer has. The daemon moves the issue once both are in — the decision GitHub reports
@@ -120,8 +121,8 @@ reviewing until you finish.
120
121
 
121
122
  - Between the reviewer's approval and retro the issue sits in `accepting` (Dispatch status
122
123
  `needs_review`), where no worker is started: the tree's architect reads the evidence (the
123
- tester's handoff, the PR body's `E2E (implementer)` and `E2E (tester)` lines, the reviewer's
124
- approval of the head by SHA, and for a parent that every child is done) and accepts with the
124
+ tester's handoff, the PR body's `E2E (implementer)` and `E2E (tester)` lines and the reviewer's
125
+ approval of the head by SHA; children still running do not hold it) and accepts with the
125
126
  `legion` tool's `sign_off`, which moves the issue to retro, or sends it back to `implementing`
126
127
  or `testing` with `request_backward_move` and a reason, which reaches the started worker's task
127
128
  as its `Reason:` line and counts a review round. The architect also reads the reviewer's own
@@ -130,7 +131,7 @@ reviewing until you finish.
130
131
  controller takes it"). The acceptance asks no `Production:` line of anyone: that line is written
131
132
  after the close (*The production check after the close*, below).
132
133
  CI red at the head in `accepting` returns the issue to `implementing` as it does in review. A
133
- head the review App pushed beyond `.legion/` is the exception: the daemon marks it a planned red
134
+ head the review App pushed beyond `.legion/<issue>/` is the exception: the daemon marks it a planned red
134
135
  and acts on it nowhere, and GitHub holds the merge under the base branch's own rules.
135
136
 
136
137
  ## Retro
@@ -8,7 +8,7 @@ sjawhar/legion.
8
8
 
9
9
  ## The shape GitHub honours
10
10
 
11
- A push whose commits touch only `.legion/` (a handoff-only push) ends its head's commit message
11
+ A push whose commits touch only `.legion/<issue>/` (a handoff-only push) ends its head's commit message
12
12
  with `skip-checks: true` as the last line, preceded by two empty lines, so GitHub starts no
13
13
  workflow run for it and the daemon carries the code head's verdict to it — except retro's push,
14
14
  which never carries the line whatever paths it touches, for the reason the next section gives.
@@ -14,8 +14,9 @@ which you have; those need no runner.
14
14
  - `psql` — the Dispatch browser suite (`bun run e2e`) resets its database for every spec through
15
15
  `packages/dispatch/e2e/psql.ts`, which `seed.ts` calls, and the local stack (`bun run
16
16
  dispatch:local`, `packages/dispatch/e2e/local-instance.ts`) refuses to start without `go`,
17
- `psql`, `flock` and `openssl` on `PATH`, and without `docker` too unless a `DATABASE_URL` is
18
- given. The image carries `go`, `flock` and `openssl`, a `DATABASE_URL` stands in for `docker`,
17
+ `psql`, `flock`, `openssl` and `python3` on `PATH`, and without `docker` too unless a
18
+ `DATABASE_URL` is given. The image carries `go`, `flock`, `openssl` and `python3`, a
19
+ `DATABASE_URL` stands in for `docker`,
19
20
  and `psql` was the one a role had no way to supply — so a role whose change owes that suite can
20
21
  run it.
21
22
  - `redis-cli` — no repository of the project speaks Redis today; it is carried anyway, at about a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "11.1.0",
3
+ "version": "11.3.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": 39
15
+ "daemonApiVersion": 40
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",