@sjawhar/pi-legion-envoy 5.24.6 → 5.26.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.
@@ -4,6 +4,7 @@ description: Diff-scoped maintainability audit for structure, abstraction, file
4
4
  # @review is the deployment's `review` model role; the Go daemon's boot gate refuses to start unless the operator's settings give
5
5
  # this agent a model, through modelRoles.review or a task.agentModelOverrides entry for it (docs/kubernetes.md, Operator configuration).
6
6
  model: ["@review"]
7
+ autoloadSkills: [thermonuclear-code-quality]
7
8
  ---
8
9
 
9
10
  # Thermonuclear Code Quality
@@ -12,7 +13,7 @@ Review only the supplied diff and changed-file context. Return findings with fil
12
13
 
13
14
  ## Process
14
15
 
15
- 1. Load `skill://thermonuclear-code-quality` and treat its rubric as complete.
16
+ 1. Apply the complete rubric of `skill://thermonuclear-code-quality`, already in your context; do not read it again.
16
17
  2. Look first for structural simplification and deletion of accidental complexity.
17
18
  3. Trace module boundaries, call sites, and type contracts before claiming a problem.
18
19
  4. Prioritize structural issues over cosmetic nits.
@@ -4,6 +4,7 @@ description: Diff-scoped security and correctness audit for bugs, breakages, dev
4
4
  # @review is the deployment's `review` model role; the Go daemon's boot gate refuses to start unless the operator's settings give
5
5
  # this agent a model, through modelRoles.review or a task.agentModelOverrides entry for it (docs/kubernetes.md, Operator configuration).
6
6
  model: ["@review"]
7
+ autoloadSkills: [thermonuclear-deep-review]
7
8
  ---
8
9
 
9
10
  # Thermonuclear Deep Review
@@ -12,7 +13,7 @@ Review only the supplied diff and changed-file context. Return findings with fil
12
13
 
13
14
  ## Process
14
15
 
15
- 1. Load `skill://thermonuclear-deep-review` and use its complete rubric.
16
+ 1. Apply the complete rubric of `skill://thermonuclear-deep-review`, already in your context; do not read it again.
16
17
  2. Trace effects across callers, package boundaries, configuration, and public contracts.
17
18
  3. Check feature gates and developer workflows when the change can affect either.
18
19
  4. Complete an independent review before reading PR discussion.
package/dist/envoy.js CHANGED
@@ -30058,17 +30058,7 @@ function componentsArgument(z2) {
30058
30058
  reason: z2.string({ min: 1 }).describe("For mode none: why this issue is not architectural (process, hiring, ops).").optional()
30059
30059
  }).describe("Attach the issue to architecture components. Attach the root before decomposing it; children inherit unless they choose.");
30060
30060
  }
30061
- var SPEC_SECTIONS = [
30062
- "Summary",
30063
- "New since we talked",
30064
- "Acceptance",
30065
- "Requirements",
30066
- "Design",
30067
- "Errors",
30068
- "Testing",
30069
- "Rejected"
30070
- ];
30071
- var SPEC_WRITING_GUIDANCE = `When writing a spec, use these sections in order: ${SPEC_SECTIONS.join(", ")}. ` + "Write for a reader who has not seen the code: plain sentences, every identifier expanded on " + "first use, no coined shorthand; see skills/dispatch Writing for the human and Writing a spec.";
30061
+ var SPEC_WRITING_POINTER = 'Write a spec as the "Writing a spec" section of skill://dispatch says.';
30072
30062
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
30073
30063
  var ASK_QUESTION_MAX = 800;
30074
30064
  var SEARCH_QUERY_MAX = 1000;
@@ -30109,7 +30099,7 @@ var dispatchToolSpecs = [
30109
30099
  parent: z2.string().describe("Optional parent issue.").optional(),
30110
30100
  external: z2.string().describe("Optional external issue reference.").optional(),
30111
30101
  force: z2.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
30112
- spec: z2.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional(),
30102
+ spec: z2.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_POINTER}`).optional(),
30113
30103
  labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
30114
30104
  priority: z2.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
30115
30105
  assignee: z2.string().describe("GitHub login of the human who answers this issue's asks; defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional(),
@@ -30296,7 +30286,7 @@ var dispatchToolSpecs = [
30296
30286
  ops: [{ op: "delete_column", block: "table-123", index: 1 }],
30297
30287
  precondition: { blocks: [{ id: "table-123", token: "sha256:current-table-token" }] }
30298
30288
  },
30299
- description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose - a list item, quote, typed block or footnote definition left holding only the emptied paragraph keeps it. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. A hard break in with is itself INVALID_OP when the matched text is in a heading or a table cell, which are written on one line. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "The result carries the document token this edit produced, so a chain of guarded edits passes each result's token as the next edit's precondition with no dispatch_doc_read between them. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + "A change a browser removes while the edit is in flight is never reported as applied: EDIT_LOST_TO_CONCURRENT_CHANGE means the write was refused and nothing was written, so re-read the document and decide again, as with PRECONDITION_FAILED; lost_ops on a successful result names operations whose text the live document no longer has, because the deletion landed after the version was written. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
30289
+ description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose - a list item, quote, typed block or footnote definition left holding only the emptied paragraph keeps it. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. A hard break in with is itself INVALID_OP when the matched text is in a heading or a table cell, which are written on one line. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. A delete or retype that would take an ask block out of the document while its ask is open is refused, with nothing sent. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "The result carries the document token this edit produced, so a chain of guarded edits passes each result's token as the next edit's precondition with no dispatch_doc_read between them. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + "A change a browser removes while the edit is in flight is never reported as applied: EDIT_LOST_TO_CONCURRENT_CHANGE means the write was refused and nothing was written, so re-read the document and decide again, as with PRECONDITION_FAILED; lost_ops on a successful result names operations whose text the live document no longer has, because the deletion landed after the version was written. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_POINTER}`,
30300
30290
  arguments: (z2) => ({
30301
30291
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
30302
30292
  project: z2.string().describe("Project key owning the document.").optional(),
@@ -30341,12 +30331,13 @@ var dispatchToolSpecs = [
30341
30331
  },
30342
30332
  {
30343
30333
  name: "dispatch_request_approval",
30344
- example: { issue: "DSP-1" },
30345
- description: "Ask a human to approve a document at its current version - the exception path for a spec " + "that departs from what was settled or proposes children, not a step for every issue. Opens an " + "approval ask (Approve / Request changes) in the human's Inbox; the answer pins a review to the " + "document version and arrives as artifact.approved or artifact.changes_requested. A later edit " + "makes an approval stale; request again for the new version. Idempotent while a request is open. " + OWNER_REFERENCE,
30334
+ example: { issue: "DSP-1", summary: "Proposes a live sync in place of the nightly export." },
30335
+ description: "Ask a human to approve a document at its current version. Opens an approval ask (Approve / " + "Request changes) in the human's Inbox whose question names the document and version, " + "followed by the summary; the answer pins a review to that version and arrives as " + "artifact.approved or artifact.changes_requested. A later version makes an approval stale, " + "and writing it retracts an open request for an older version; request again for the new " + "one. A repeat at the version an open request names returns that request unchanged. " + "Refused, with nothing sent, while the document holds an open decision block, even when a " + "human asked for approval: the refusal names each block; ask the human to answer or waive " + "it first. " + OWNER_REFERENCE,
30346
30336
  arguments: (z2) => ({
30347
30337
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
30348
30338
  project: z2.string().describe("Project key owning the document.").optional(),
30349
- artifact: z2.string().describe("Project document artifact id, slug, or filename; primary document by default for an issue.").optional()
30339
+ artifact: z2.string().describe("Project document artifact id, slug, or filename; primary document by default for an issue.").optional(),
30340
+ summary: z2.string({ min: 1 }).describe("The proposals in this version the human hasn't already agreed to, in one to three sentences.")
30350
30341
  }),
30351
30342
  validation: documentOwnerValidation(true)
30352
30343
  },
@@ -33747,6 +33738,84 @@ async function openArtifactMarks(client, resolved) {
33747
33738
  ...commentsResult.value.filter((comment) => !comment.resolved && comment.anchor?.artifact_id === resolved.artifact.id).map((comment) => `comment ${comment.id}`)
33748
33739
  ];
33749
33740
  }
33741
+ async function blockAsks(client, resolved, state) {
33742
+ const asks = await (resolved.issue === undefined ? client.getArtifactAsks(resolved.artifact.id, state) : client.listIssueAsks(resolved.issue.key, state));
33743
+ return asks.filter((ask) => typeof ask.block_id === "string" && ask.block_artifact?.id === resolved.artifact.id);
33744
+ }
33745
+ async function refuseOpenDecisionBlocks(client, tool, resolved) {
33746
+ const artifact = resolved.artifact;
33747
+ const latest = artifact.approval?.latest_version;
33748
+ if (latest === undefined || latest < 1 || artifact.approval?.state === "approved")
33749
+ return;
33750
+ const blocks = (await client.artifactBlocks(artifact.id)).filter((block) => block.type === "ask");
33751
+ if (blocks.length === 0)
33752
+ return;
33753
+ const [documentAsks, version2] = await Promise.all([
33754
+ blockAsks(client, resolved),
33755
+ client.docRead(artifact.id, latest)
33756
+ ]);
33757
+ const asks = new Map(documentAsks.map((ask) => [ask.block_id, ask]));
33758
+ const lines = version2.markdown.split(`
33759
+ `);
33760
+ const open = blocks.flatMap((block) => {
33761
+ const ask = asks.get(block.id);
33762
+ const named = ask === undefined ? `block ${block.id}` : `${JSON.stringify(ask.question)} (block ${block.id}, ask ${ask.id})`;
33763
+ const states = lines.filter((line) => line.includes(`ask{#${block.id} `) || line.includes(`ask{#${block.id}}`)).map((line) => /\bstate="(\w+)"/.exec(line)?.[1]);
33764
+ if (states.length === 0)
33765
+ return [`${named}, which version ${latest} does not hold yet`];
33766
+ if (!states.includes("open") && states.some((state) => state !== undefined))
33767
+ return [];
33768
+ if (ask === undefined)
33769
+ return [`${named}, whose ask Dispatch has not opened yet`];
33770
+ if (ask.state === "open")
33771
+ return [named];
33772
+ const next = ask.state === "answered" ? "fold the answer into the text" : "write the decision into the text";
33773
+ return [
33774
+ `${named}, ${ask.state} but still open in version ${latest}: ${next} with dispatch_doc_edit, which writes a version that carries it`
33775
+ ];
33776
+ });
33777
+ if (open.length === 0)
33778
+ return;
33779
+ const count = open.length === 1 ? "1 open decision block" : `${open.length} open decision blocks`;
33780
+ throw new Error([
33781
+ `${tool} was not called: ${artifact.name} (version ${latest}) has ${count}. Answering one writes a new version, which would retract this request.`,
33782
+ ...open.map((line) => `- ${line}`),
33783
+ "Do not request approval over an open block, even when a human asked for it. Tell the human which block is open and ask them to answer it or to waive it. Once it is answered, fold the answer into the text with dispatch_doc_edit and request approval again. If they waive it, close the block with dispatch_resolve_ask (kind resolved, their words as the reason), write their decision into the text with dispatch_doc_edit, and request approval again."
33784
+ ].join(`
33785
+ `));
33786
+ }
33787
+ async function refuseRemovingOpenDecisionBlocks(client, tool, resolved, ops) {
33788
+ const removing = ops.filter((operation) => operation.block !== undefined && (operation.op === "delete" || operation.op === "retype" && operation.type !== "ask"));
33789
+ if (removing.length === 0)
33790
+ return;
33791
+ const artifact = resolved.artifact;
33792
+ const blocks = await client.artifactBlocks(artifact.id);
33793
+ const askBlocks = blocks.filter((block) => block.type === "ask");
33794
+ const removed = new Set;
33795
+ for (const operation of removing) {
33796
+ const target = blocks.find((block) => block.id === operation.block);
33797
+ if (target === undefined)
33798
+ continue;
33799
+ for (const block of askBlocks) {
33800
+ if (block.id === target.id || operation.op === "delete" && block.from >= target.from && block.to <= target.to) {
33801
+ removed.add(block.id);
33802
+ }
33803
+ }
33804
+ }
33805
+ if (removed.size === 0)
33806
+ return;
33807
+ const asks = await blockAsks(client, resolved, "open");
33808
+ const open = asks.filter((ask) => removed.has(ask.block_id));
33809
+ if (open.length === 0)
33810
+ return;
33811
+ const [what, question] = open.length === 1 ? ["a decision block whose ask is", "question"] : [`${open.length} decision blocks whose asks are`, "questions"];
33812
+ throw new Error([
33813
+ `${tool} was not called: it would remove ${what} still open, and the human's ${question} would leave their Inbox unanswered.`,
33814
+ ...open.map((ask) => `- ${JSON.stringify(ask.question)} (block ${ask.block_id}, ask ${ask.id})`),
33815
+ "A decision block leaves the document once its ask is answered or resolved. Until then, reword it with replace, relocate it with move, or change its question, options, urgency or multiple with dispatch_edit_ask if you asked it; each keeps it."
33816
+ ].join(`
33817
+ `));
33818
+ }
33750
33819
  function refusalWithCode(error48, suffix = "") {
33751
33820
  if (!(error48 instanceof DispatchServiceError))
33752
33821
  return error48;
@@ -34307,6 +34376,7 @@ ${followsAsk(askOwner)}`,
34307
34376
  const summary = optionalString(args, "summary");
34308
34377
  const { precondition: rawPrecondition } = args;
34309
34378
  const precondition = rawPrecondition;
34379
+ await refuseRemovingOpenDecisionBlocks(client, input.tool, resolved, ops);
34310
34380
  const edited = await client.docEdit(resolved.artifact.id, {
34311
34381
  ops,
34312
34382
  ...summary === undefined ? {} : { summary },
@@ -34374,7 +34444,11 @@ ${trailer.join(`
34374
34444
  case "dispatch_request_approval": {
34375
34445
  const artifactReference = optionalString(args, "artifact") ?? (ownerArguments.ref?.kind === "spec" || ownerArguments.ref?.kind === "artifact" ? ownerArguments.ref.id : undefined);
34376
34446
  const resolved = await resolveDocument(documentOwner(), artifactReference);
34377
- const result = await client.requestApproval(resolved.artifact.id, { actor });
34447
+ await refuseOpenDecisionBlocks(client, input.tool, resolved);
34448
+ const result = await client.requestApproval(resolved.artifact.id, {
34449
+ actor,
34450
+ summary: stringArg(args, "summary")
34451
+ });
34378
34452
  if (result.ask === null) {
34379
34453
  return {
34380
34454
  text: `${resolved.artifact.name} (document id ${resolved.artifact.id}) is already approved at version ${result.version} by ${result.approval.by?.id ?? "unknown"}; no new request was opened. An edit after approval makes it stale, so request again only for a new version.`,
@@ -34387,7 +34461,7 @@ ${trailer.join(`
34387
34461
  }
34388
34462
  const details = await followedAskDetails(client, result.ask, resolved.artifact);
34389
34463
  return {
34390
- text: `Approval requested for ${resolved.artifact.name} (document id ${resolved.artifact.id}) at version ${result.version} (ask ${result.ask.id}). The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
34464
+ text: `Approval requested for ${resolved.artifact.name} (document id ${resolved.artifact.id}) at version ${result.version} (ask ${result.ask.id}). The human's Inbox asks: ${JSON.stringify(result.ask.question)}. The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
34391
34465
  details: { ...details, artifact: resolved.artifact.id, version: result.version }
34392
34466
  };
34393
34467
  }
@@ -35582,7 +35656,7 @@ var ECHOED_CHOICE = /\bPROCEEDING\b/;
35582
35656
  var isWaitingVerdict = (reply) => WAITING_VERDICT.test(reply) && !ECHOED_CHOICE.test(reply);
35583
35657
  var ASK_SELF_CHECK_TIMEOUT_MS = 60000;
35584
35658
  var ASK_CHECKS_PER_PERIOD = 5;
35585
- var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human for something no open ask in Dispatch covers. Open an ask for it now with dispatch_ask (or dispatch_request_approval for a document), naming exactly what you need and from whom.";
35659
+ var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human for something no open ask in Dispatch covers. Open an ask for it now with dispatch_ask, naming exactly what you need and from whom.";
35586
35660
  var ASK_OPENING_TOOLS = ["dispatch_ask", "dispatch_request_approval"];
35587
35661
  var DISPATCH_TOOL_PREFIX = "dispatch_";
35588
35662
  function isLegionManagedEntry(entry) {
package/dist/legion.js CHANGED
@@ -30227,17 +30227,7 @@ function componentsArgument(z2) {
30227
30227
  reason: z2.string({ min: 1 }).describe("For mode none: why this issue is not architectural (process, hiring, ops).").optional()
30228
30228
  }).describe("Attach the issue to architecture components. Attach the root before decomposing it; children inherit unless they choose.");
30229
30229
  }
30230
- var SPEC_SECTIONS = [
30231
- "Summary",
30232
- "New since we talked",
30233
- "Acceptance",
30234
- "Requirements",
30235
- "Design",
30236
- "Errors",
30237
- "Testing",
30238
- "Rejected"
30239
- ];
30240
- var SPEC_WRITING_GUIDANCE = `When writing a spec, use these sections in order: ${SPEC_SECTIONS.join(", ")}. ` + "Write for a reader who has not seen the code: plain sentences, every identifier expanded on " + "first use, no coined shorthand; see skills/dispatch Writing for the human and Writing a spec.";
30230
+ var SPEC_WRITING_POINTER = 'Write a spec as the "Writing a spec" section of skill://dispatch says.';
30241
30231
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
30242
30232
  var ASK_QUESTION_MAX = 800;
30243
30233
  var SEARCH_QUERY_MAX = 1000;
@@ -30278,7 +30268,7 @@ var dispatchToolSpecs = [
30278
30268
  parent: z2.string().describe("Optional parent issue.").optional(),
30279
30269
  external: z2.string().describe("Optional external issue reference.").optional(),
30280
30270
  force: z2.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
30281
- spec: z2.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional(),
30271
+ spec: z2.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_POINTER}`).optional(),
30282
30272
  labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
30283
30273
  priority: z2.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
30284
30274
  assignee: z2.string().describe("GitHub login of the human who answers this issue's asks; defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional(),
@@ -30465,7 +30455,7 @@ var dispatchToolSpecs = [
30465
30455
  ops: [{ op: "delete_column", block: "table-123", index: 1 }],
30466
30456
  precondition: { blocks: [{ id: "table-123", token: "sha256:current-table-token" }] }
30467
30457
  },
30468
- description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose - a list item, quote, typed block or footnote definition left holding only the emptied paragraph keeps it. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. A hard break in with is itself INVALID_OP when the matched text is in a heading or a table cell, which are written on one line. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "The result carries the document token this edit produced, so a chain of guarded edits passes each result's token as the next edit's precondition with no dispatch_doc_read between them. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + "A change a browser removes while the edit is in flight is never reported as applied: EDIT_LOST_TO_CONCURRENT_CHANGE means the write was refused and nothing was written, so re-read the document and decide again, as with PRECONDITION_FAILED; lost_ops on a successful result names operations whose text the live document no longer has, because the deletion landed after the version was written. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
30458
+ description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose - a list item, quote, typed block or footnote definition left holding only the emptied paragraph keeps it. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. A hard break in with is itself INVALID_OP when the matched text is in a heading or a table cell, which are written on one line. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. A delete or retype that would take an ask block out of the document while its ask is open is refused, with nothing sent. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "The result carries the document token this edit produced, so a chain of guarded edits passes each result's token as the next edit's precondition with no dispatch_doc_read between them. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + "A change a browser removes while the edit is in flight is never reported as applied: EDIT_LOST_TO_CONCURRENT_CHANGE means the write was refused and nothing was written, so re-read the document and decide again, as with PRECONDITION_FAILED; lost_ops on a successful result names operations whose text the live document no longer has, because the deletion landed after the version was written. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_POINTER}`,
30469
30459
  arguments: (z2) => ({
30470
30460
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
30471
30461
  project: z2.string().describe("Project key owning the document.").optional(),
@@ -30510,12 +30500,13 @@ var dispatchToolSpecs = [
30510
30500
  },
30511
30501
  {
30512
30502
  name: "dispatch_request_approval",
30513
- example: { issue: "DSP-1" },
30514
- description: "Ask a human to approve a document at its current version - the exception path for a spec " + "that departs from what was settled or proposes children, not a step for every issue. Opens an " + "approval ask (Approve / Request changes) in the human's Inbox; the answer pins a review to the " + "document version and arrives as artifact.approved or artifact.changes_requested. A later edit " + "makes an approval stale; request again for the new version. Idempotent while a request is open. " + OWNER_REFERENCE,
30503
+ example: { issue: "DSP-1", summary: "Proposes a live sync in place of the nightly export." },
30504
+ description: "Ask a human to approve a document at its current version. Opens an approval ask (Approve / " + "Request changes) in the human's Inbox whose question names the document and version, " + "followed by the summary; the answer pins a review to that version and arrives as " + "artifact.approved or artifact.changes_requested. A later version makes an approval stale, " + "and writing it retracts an open request for an older version; request again for the new " + "one. A repeat at the version an open request names returns that request unchanged. " + "Refused, with nothing sent, while the document holds an open decision block, even when a " + "human asked for approval: the refusal names each block; ask the human to answer or waive " + "it first. " + OWNER_REFERENCE,
30515
30505
  arguments: (z2) => ({
30516
30506
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
30517
30507
  project: z2.string().describe("Project key owning the document.").optional(),
30518
- artifact: z2.string().describe("Project document artifact id, slug, or filename; primary document by default for an issue.").optional()
30508
+ artifact: z2.string().describe("Project document artifact id, slug, or filename; primary document by default for an issue.").optional(),
30509
+ summary: z2.string({ min: 1 }).describe("The proposals in this version the human hasn't already agreed to, in one to three sentences.")
30519
30510
  }),
30520
30511
  validation: documentOwnerValidation(true)
30521
30512
  },
@@ -32686,6 +32677,84 @@ async function openArtifactMarks(client, resolved) {
32686
32677
  ...commentsResult.value.filter((comment) => !comment.resolved && comment.anchor?.artifact_id === resolved.artifact.id).map((comment) => `comment ${comment.id}`)
32687
32678
  ];
32688
32679
  }
32680
+ async function blockAsks(client, resolved, state) {
32681
+ const asks = await (resolved.issue === undefined ? client.getArtifactAsks(resolved.artifact.id, state) : client.listIssueAsks(resolved.issue.key, state));
32682
+ return asks.filter((ask) => typeof ask.block_id === "string" && ask.block_artifact?.id === resolved.artifact.id);
32683
+ }
32684
+ async function refuseOpenDecisionBlocks(client, tool, resolved) {
32685
+ const artifact = resolved.artifact;
32686
+ const latest = artifact.approval?.latest_version;
32687
+ if (latest === undefined || latest < 1 || artifact.approval?.state === "approved")
32688
+ return;
32689
+ const blocks = (await client.artifactBlocks(artifact.id)).filter((block) => block.type === "ask");
32690
+ if (blocks.length === 0)
32691
+ return;
32692
+ const [documentAsks, version2] = await Promise.all([
32693
+ blockAsks(client, resolved),
32694
+ client.docRead(artifact.id, latest)
32695
+ ]);
32696
+ const asks = new Map(documentAsks.map((ask) => [ask.block_id, ask]));
32697
+ const lines = version2.markdown.split(`
32698
+ `);
32699
+ const open = blocks.flatMap((block) => {
32700
+ const ask = asks.get(block.id);
32701
+ const named = ask === undefined ? `block ${block.id}` : `${JSON.stringify(ask.question)} (block ${block.id}, ask ${ask.id})`;
32702
+ const states = lines.filter((line) => line.includes(`ask{#${block.id} `) || line.includes(`ask{#${block.id}}`)).map((line) => /\bstate="(\w+)"/.exec(line)?.[1]);
32703
+ if (states.length === 0)
32704
+ return [`${named}, which version ${latest} does not hold yet`];
32705
+ if (!states.includes("open") && states.some((state) => state !== undefined))
32706
+ return [];
32707
+ if (ask === undefined)
32708
+ return [`${named}, whose ask Dispatch has not opened yet`];
32709
+ if (ask.state === "open")
32710
+ return [named];
32711
+ const next = ask.state === "answered" ? "fold the answer into the text" : "write the decision into the text";
32712
+ return [
32713
+ `${named}, ${ask.state} but still open in version ${latest}: ${next} with dispatch_doc_edit, which writes a version that carries it`
32714
+ ];
32715
+ });
32716
+ if (open.length === 0)
32717
+ return;
32718
+ const count = open.length === 1 ? "1 open decision block" : `${open.length} open decision blocks`;
32719
+ throw new Error([
32720
+ `${tool} was not called: ${artifact.name} (version ${latest}) has ${count}. Answering one writes a new version, which would retract this request.`,
32721
+ ...open.map((line) => `- ${line}`),
32722
+ "Do not request approval over an open block, even when a human asked for it. Tell the human which block is open and ask them to answer it or to waive it. Once it is answered, fold the answer into the text with dispatch_doc_edit and request approval again. If they waive it, close the block with dispatch_resolve_ask (kind resolved, their words as the reason), write their decision into the text with dispatch_doc_edit, and request approval again."
32723
+ ].join(`
32724
+ `));
32725
+ }
32726
+ async function refuseRemovingOpenDecisionBlocks(client, tool, resolved, ops) {
32727
+ const removing = ops.filter((operation) => operation.block !== undefined && (operation.op === "delete" || operation.op === "retype" && operation.type !== "ask"));
32728
+ if (removing.length === 0)
32729
+ return;
32730
+ const artifact = resolved.artifact;
32731
+ const blocks = await client.artifactBlocks(artifact.id);
32732
+ const askBlocks = blocks.filter((block) => block.type === "ask");
32733
+ const removed = new Set;
32734
+ for (const operation of removing) {
32735
+ const target = blocks.find((block) => block.id === operation.block);
32736
+ if (target === undefined)
32737
+ continue;
32738
+ for (const block of askBlocks) {
32739
+ if (block.id === target.id || operation.op === "delete" && block.from >= target.from && block.to <= target.to) {
32740
+ removed.add(block.id);
32741
+ }
32742
+ }
32743
+ }
32744
+ if (removed.size === 0)
32745
+ return;
32746
+ const asks = await blockAsks(client, resolved, "open");
32747
+ const open = asks.filter((ask) => removed.has(ask.block_id));
32748
+ if (open.length === 0)
32749
+ return;
32750
+ const [what, question] = open.length === 1 ? ["a decision block whose ask is", "question"] : [`${open.length} decision blocks whose asks are`, "questions"];
32751
+ throw new Error([
32752
+ `${tool} was not called: it would remove ${what} still open, and the human's ${question} would leave their Inbox unanswered.`,
32753
+ ...open.map((ask) => `- ${JSON.stringify(ask.question)} (block ${ask.block_id}, ask ${ask.id})`),
32754
+ "A decision block leaves the document once its ask is answered or resolved. Until then, reword it with replace, relocate it with move, or change its question, options, urgency or multiple with dispatch_edit_ask if you asked it; each keeps it."
32755
+ ].join(`
32756
+ `));
32757
+ }
32689
32758
  function refusalWithCode(error48, suffix = "") {
32690
32759
  if (!(error48 instanceof DispatchServiceError))
32691
32760
  return error48;
@@ -33246,6 +33315,7 @@ ${followsAsk(askOwner)}`,
33246
33315
  const summary = optionalString(args, "summary");
33247
33316
  const { precondition: rawPrecondition } = args;
33248
33317
  const precondition = rawPrecondition;
33318
+ await refuseRemovingOpenDecisionBlocks(client, input.tool, resolved, ops);
33249
33319
  const edited = await client.docEdit(resolved.artifact.id, {
33250
33320
  ops,
33251
33321
  ...summary === undefined ? {} : { summary },
@@ -33313,7 +33383,11 @@ ${trailer.join(`
33313
33383
  case "dispatch_request_approval": {
33314
33384
  const artifactReference = optionalString(args, "artifact") ?? (ownerArguments.ref?.kind === "spec" || ownerArguments.ref?.kind === "artifact" ? ownerArguments.ref.id : undefined);
33315
33385
  const resolved = await resolveDocument(documentOwner(), artifactReference);
33316
- const result = await client.requestApproval(resolved.artifact.id, { actor });
33386
+ await refuseOpenDecisionBlocks(client, input.tool, resolved);
33387
+ const result = await client.requestApproval(resolved.artifact.id, {
33388
+ actor,
33389
+ summary: stringArg(args, "summary")
33390
+ });
33317
33391
  if (result.ask === null) {
33318
33392
  return {
33319
33393
  text: `${resolved.artifact.name} (document id ${resolved.artifact.id}) is already approved at version ${result.version} by ${result.approval.by?.id ?? "unknown"}; no new request was opened. An edit after approval makes it stale, so request again only for a new version.`,
@@ -33326,7 +33400,7 @@ ${trailer.join(`
33326
33400
  }
33327
33401
  const details = await followedAskDetails(client, result.ask, resolved.artifact);
33328
33402
  return {
33329
- text: `Approval requested for ${resolved.artifact.name} (document id ${resolved.artifact.id}) at version ${result.version} (ask ${result.ask.id}). The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
33403
+ text: `Approval requested for ${resolved.artifact.name} (document id ${resolved.artifact.id}) at version ${result.version} (ask ${result.ask.id}). The human's Inbox asks: ${JSON.stringify(result.ask.question)}. The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
33330
33404
  details: { ...details, artifact: resolved.artifact.id, version: result.version }
33331
33405
  };
33332
33406
  }
@@ -33537,7 +33611,7 @@ import { logger } from "@oh-my-pi/pi-utils";
33537
33611
  // package.json
33538
33612
  var package_default = {
33539
33613
  name: "@sjawhar/pi-legion-envoy",
33540
- version: "5.24.6",
33614
+ version: "5.26.0",
33541
33615
  type: "module",
33542
33616
  omp: {
33543
33617
  extensions: [
@@ -16,7 +16,7 @@ event intake, process lifecycle, credentials, and role delivery.
16
16
  | `legion-retro/` | the implementer, at retro | the pre-merge retrospective and its Dispatch message |
17
17
  | `legion-worker/` | planner, implementer, tester, reviewer, merger | the phase contracts: handoffs, GitHub identity, PR body and READY discipline, the merge-gate order |
18
18
  | `thermonuclear-code-quality/` | the `thermonuclear-code-quality` agent | the maintainability rubric of the reviewer's pair |
19
- | `thermonuclear-deep-review/` | the `thermonuclear-deep-review` agent | the security and correctness rubric of the reviewer's pair |
19
+ | `thermonuclear-deep-review/` | the `thermonuclear-deep-review` agent, and the reviewer (the Security Guidelines) | the correctness rubric of the reviewer's pair, with its tagged, diff-triggered Security Guidelines and the attack on the PR body's claims |
20
20
 
21
21
  The owning skill above is where each contract is defined; a role prompt that needs a contract from its own seat points there or restates only its own step. This file lists and does not restate.
22
22
  A Legion prompt (a skill here, a role prompt, or an agent definition in `packages/pi-envoy/agents/`) names a task agent only as `task(agent="<name>")` and a skill it tells the model to load only as `skill://<name>`. Those are the two forms the Go daemon's boot gate and `legion probe-image` resolve through Oh My Pi, refusing by name one it cannot find; a dispatch or a load written any other way goes unchecked. An agent or skill Legion's prompts name is shipped here or in `packages/pi-envoy/agents/`, unless Oh My Pi bundles it.
@@ -34,18 +34,10 @@ after `skill://dispatch/` is relative to this skill's base directory.
34
34
 
35
35
  ## Design changes are brainstormed here
36
36
 
37
- Sami, 2026-09-17, verbatim: "Make sure your agents know that they should be doing brainstorming with me
38
- through dispatch for major design changes." For a major design change the design conversation itself
39
- happens in Dispatch: write the spec document early, while it is still a draft with real alternatives, and
40
- put each open question in it as an `ask` block beside the options and trade-offs it depends on
41
- ([Writing a spec](#writing-a-spec), [Typed blocks](#typed-blocks)). He answers in place and the document
42
- grows into the record. A finished spec dropped after a chat-only design, or a set of one-line issue asks
43
- pointing at a document, is not brainstorming with him.
44
- Sami, 2026-09-17, verbatim: "Can you please stop doing this thing where you have these one-off,
45
- shorthand, compressed decision asks that are completely disconnected from any discussion of the
46
- design or the trade-offs? This is just very obviously not the most effective way to have a design
47
- communication." A question lives beside the options and trade-offs it depends on, in the spec or
48
- discussion it came from — never as a compressed standalone ask.
37
+ For a major design change the design conversation happens in Dispatch, in the spec: write it
38
+ early, while it is still a draft with real alternatives, and let it grow as the human answers
39
+ ([Writing a spec](#writing-a-spec)). A finished spec dropped after a chat-only design is not that
40
+ conversation.
49
41
 
50
42
  ## Writing for the human
51
43
 
@@ -56,10 +48,10 @@ not share this session's vocabulary, and is often on a phone. Write for that per
56
48
  not "fix 8c", "READY-target", "PR B", "spec@v3", "the pair", "the packet" — say what the thing is.
57
49
  - Expand every identifier the first time it appears: an issue key gets its title, a PR number its
58
50
  title, a file what it is for, a session id who it is. Link a URL rather than pasting a bare id.
59
- - A question lives beside the options and trade-offs it depends on, in the spec or discussion it
60
- came from — never a compressed standalone ask (his words are quoted under [Design changes are
61
- brainstormed here](#design-changes-are-brainstormed-here)). Give the reader the options, what
62
- each costs, and your recommendation with its reason; do not prescribe yourself a form.
51
+ - A question lives in the spec or discussion it came from, placed as
52
+ [Decision blocks](#decision-blocks) says, never as a compressed standalone ask. Give the reader
53
+ the options, what each costs, and your recommendation with its reason; do not prescribe yourself
54
+ a form.
63
55
  - Describe a change by what its reader stands to lose, not by what the system does. The
64
56
  engineering sentence names the change; the reader's sentence names who can do what today, what
65
57
  they will not be able to do after it, what still works, and what you cannot tell. It is a
@@ -76,39 +68,40 @@ not share this session's vocabulary, and is often on a phone. Write for that per
76
68
 
77
69
  ## Writing a spec
78
70
 
79
- A spec has two readers: the human who decides reads the **Summary** and **New since we talked** at the top, then each decision through its [`:::ask` block](#decision-blocks) where it arises; the implementer who builds reads the rest. Use these headings in this order.
80
-
81
- | Section | Required content | Form |
82
- | --- | --- | --- |
83
- | **Summary** | The problem, what changes for whom, and how we will know it worked — in plain words. | Three sentences at most. |
84
- | **New since we talked** | Every design point the human did not settle in conversation, marked `inferred:` with the reasoning. Empty is fine. | One plain sentence per point. |
85
- | **Acceptance** | Each outcome names what a user will observe and the check that proves it (browser scenario, API call, or command). An outcome without a check is not acceptance. | Numbered lines. |
86
- | **Requirements** | What must hold, and where each came from: a quoted human sentence, or `inferred:` plus the reasoning. Readers treat inferred requirements as hypotheses. | `requirement \| where it comes from` table, or prose if the reader follows it more easily. |
87
- | **Design** | The files, components, routes, and data flow that change. | Prose or tables; a diagram only for real structure. |
88
- | **Errors** | The behaviour for every error condition. Never a silent fallback. | `condition \| behaviour` table. |
89
- | **Testing** | Which proof exercises each acceptance line. | One line per acceptance item. |
90
- | **Rejected** | Each alternative considered and why it was rejected, so it is not proposed again. | One alternative per line. |
91
-
92
- ### Rules
93
-
94
- - The spec is the issue's one primary document. Extend it in place — a new version that keeps the
95
- human's own text — never a second "spec" artifact beside it.
96
- - No hedging ("might", "could consider"). No TBD, TODO, or placeholders: an open item is an ask
97
- block, a technical decision your lane makes and records as a Requirement, or, for a contract
98
- between two lanes or a halt condition, a question for the platform PO (see
71
+ A spec is the design conversation with the human, written down. It starts as the problem and the
72
+ evidence for it, in plain words: what goes wrong, for whom, and the counts or cases that show it.
73
+ It grows in place as the conversation goes. It is the issue's one primary document: extend it with
74
+ a new version that keeps the human's own text, never a second "spec" artifact beside it.
75
+
76
+ - **Each open question is a decision block**, placed as [Decision blocks](#decision-blocks) says.
77
+ Because it is an ask, it reaches the human's Inbox, and the answer lands next to its context.
78
+ - **A settled point records the human's own words and the date**, quoted, so no reader mistakes
79
+ it for your inference. A point you inferred says so, with the reasoning.
80
+ - **Sections follow the topic.** No heading is required and none has a fixed place; name each
81
+ section for what it discusses.
82
+ - **A changed point is rewritten, not appended to.** When an answer or a new fact changes the
83
+ design, rewrite the text it changes and fold the answered decision into it; the document's
84
+ versions keep the history.
85
+ - **No placeholders.** No TBD, TODO, or hedging ("might", "could consider"): an open item is a
86
+ decision block, a technical decision your lane makes and records in the text, or, for a
87
+ contract between two lanes or a halt condition, a question for the platform PO (see
99
88
  [Before you ask](#before-you-ask) under Asking).
100
- - Keep each section to one screen; work that exceeds one screen per section is two specs.
101
- - Update the spec as decisions land: the spec is the record, comments are the discussion. It
102
- records decisions and requirements, never progress: no status, timestamps, "Update HH:MMZ"
103
- section, PR list, or handoff notes. Progress is not a Dispatch object at all; it lives in your
104
- transcript and your pull request (see [Messages](#messages)).
105
- - Before sending it: no sections conflict, every requirement has exactly one reading, and the
106
- Summary and every ask block pass the phone test above.
89
+ - **No progress.** The spec records the design and its decisions, never status, timestamps, an
90
+ "Update HH:MMZ" section, a pull-request list, or handoff notes. Progress is not a Dispatch
91
+ object at all; it lives in your transcript and your pull request (see [Messages](#messages)).
92
+
93
+ Before a new version goes out, read it as the human will: no two passages conflict, each point has
94
+ one reading, and every decision block passes the phone test above. A worked example is
95
+ `dispatch://LEGION-386/artifact/spec@v5`: the problem and its counts, what the human settled in his
96
+ own words, a section for each part of the design, and its one open question as a decision block
97
+ after the section on models.
107
98
 
108
99
  ## Decision blocks
109
100
 
110
- A decision a human must make is an `:::ask` block where the decision arises in the spec: inside
111
- the section whose content it is about, never gathered into a list at the top or bottom.
101
+ A decision a human must make is an `:::ask` block at the end of the section that discusses it,
102
+ carrying the options, what each costs, and your recommendation. Never gather decisions into a
103
+ list, at the top, at the bottom or in an "open questions" section, and never ask one as a
104
+ standalone `dispatch_ask` that points at the spec.
112
105
 
113
106
  The block is what reaches the human's Inbox. A question phrased as prose in the spec reaches
114
107
  nobody. A spec with no ask blocks is fine only when the issue genuinely needs no human decision.
@@ -122,11 +115,11 @@ only as `:::ask{…}` on a line of its own), or any `dispatch_doc_edit`: after a
122
115
  ask, `dispatch_doc_read` the section and check it renders as `:::ask{#<id> …}` on its own line.
123
116
 
124
117
  **Wrong:** a **Decisions needed** list at the top of the spec with three bullets.
125
- **Right:** put each decision in the design section it belongs to as an `:::ask{#slug}` block, with
126
- 2–4 options, a recommendation, and surrounding prose that explains the trade-off.
118
+ **Right:** each decision an `:::ask{#slug}` block at the end of the design section that discusses
119
+ it, with 2–4 options, a recommendation, and surrounding prose that explains the trade-off.
127
120
 
128
121
  A spec that already has the pile is repaired with `move`, not rewritten: `dispatch_doc_edit` with
129
- `{ op: "move", block: "<block-uuid>", after: "<the sentence that states the options>" }` relocates
122
+ `{ op: "move", block: "<block-uuid>", after: "<the last sentence of the section that discusses it>" }` relocates
130
123
  the block and keeps its ask, its answer and its followers; the context paragraphs that were lifted
131
124
  out of Design move the same way, and the emptied section is deleted. An ask block has two ids that
132
125
  differ; [Editing a document](skill://dispatch/references/document-edits.md) says which.
@@ -171,7 +164,7 @@ absolute link. Cite the hit you build on (`dispatch://KEY` or the document refer
171
164
  Read them; reference the existing issue, or repeat the call with `force: true` when it is genuinely new work.
172
165
  The check compares title words only (shared stemmed terms), never meaning: "four tests that fail a
173
166
  merge" pairs with "four CI gates that cannot fail a merge". So when you force past a candidate, give
174
- the new issue a title that names what differs where you can, and open its spec's Summary with the
167
+ the new issue a title that names what differs where you can, and open its spec with the
175
168
  distinction from the named issue, citing it (`dispatch://KEY`), for whoever reads the next pairing.
176
169
 
177
170
  ### Symptom versus cause
@@ -366,25 +359,33 @@ See [Following](#following) for why you receive what happens to asks you open an
366
359
 
367
360
  ## Approval of a spec
368
361
 
369
- Approval is a property of a document, not a question you phrase: a human approves a specific version, the way a pull-request
370
- review approves a commit, and any later edit makes that approval stale. It is the exception, not a step for every issue - reach
371
- for it when a spec departs from what the human already settled, proposes children, or when the project has armed a design gate.
372
-
373
- ```
374
- dispatch_request_approval({ issue?, project?, artifact? })
375
- ```
376
-
377
- Opens (or returns the open) approval ask for the document at its latest version - options `Approve` and `Request changes`, in
378
- the human's Inbox like any ask. The answer reaches you as `artifact.approved` or `artifact.changes_requested` with the pinned
379
- `version`; `changes_requested` carries the reason, which is your next piece of work. `dispatch_read` and `dispatch_doc_read` show
380
- the document's approval state; `stale` means it was approved and then edited - request again for the new version. Never write
381
- "Approve" options into an ordinary `dispatch_ask`, and never approve anything yourself: only humans review.
362
+ Approval is a property of a document, not a question you phrase: a human approves a specific
363
+ version, the way a pull-request review approves a commit, and any later version makes that
364
+ approval stale. Answering a decision block writes a new version, so request approval only when
365
+ both hold: every decision block is settled, which means answered and folded into the text, or
366
+ waived as the next paragraph says; and it proposes something the human has not already settled.
367
+ A Legion root spec under an armed design gate always goes to approval once its blocks are settled
368
+ (`skill://legion-architect`).
369
+
370
+ When both hold, request it in the pass that finishes the spec: a design waiting with nothing in
371
+ the human's Inbox waits on nobody. A choice you can make yourself is not a decision block
372
+ ([Before you ask](#before-you-ask), gate 1): write your call and its reason into the design and
373
+ name it in `summary`; a human who disagrees answers `Request changes`. When a human asks for
374
+ approval while a block is open, do not request it and do not hold it silently: name each open
375
+ block and ask them to answer it or waive it. For a waiver, close the block with `dispatch_resolve_ask`
376
+ (`kind: "resolved"`, their words as `reason`), then write their decision into the text in their
377
+ words and request. `dispatch_request_approval` refuses while any block is open.
378
+
379
+ `summary` names the proposals in this version the human has not agreed to, in one to three
380
+ sentences, and never an open question: the Inbox shows it after "Approve spec.md (version N)?".
381
+ Never write "Approve" options into an ordinary `dispatch_ask`; only humans approve. The call, its
382
+ result and its answer: [Approval requests](skill://dispatch/references/documents.md#approval-requests).
382
383
 
383
384
  ## The Spec
384
385
 
385
- The spec holds requirements, design, acceptance, decisions, and rejected alternatives, structured per [Writing a spec](#writing-a-spec).
386
- It changes only when a decision or requirement changes, and every version that records one is named with `summary`. What it
387
- never carries is in [Rules](#rules) under Writing a spec.
386
+ The spec holds the design and the decisions that shaped it, written as [Writing a spec](#writing-a-spec)
387
+ says, which also lists what it never carries. It changes when the conversation changes it, and
388
+ every version that records a decision is named with `dispatch_doc_edit`'s `summary`.
388
389
 
389
390
  Read the current document before changing it:
390
391
 
@@ -62,8 +62,8 @@ dispatch_resolve_ask({
62
62
  Use `retracted` when the question is obsolete and `resolved` when you found the answer. Include the reason because the question remains
63
63
  in its Conversation card and reply thread; a reason beginning `removed from the document in version` is refused, because that is how a
64
64
  retraction the document's own settlement wrote is recognised. Resolving a block ask records it in the block too, so it stays resolved
65
- however the document moves afterwards — while deleting the block from the document is the other way to close one, and putting the block
66
- back reopens it. Resolution is not an answer: it never records a human decision, and an answered ask cannot be
65
+ however the document moves afterwards. `dispatch_doc_edit` refuses deleting the block of an open ask; a block removed another way, by a
66
+ whole-document `dispatch_artifact` replace or by a person in the browser, closes its ask, and putting the block back reopens it. Resolution is not an answer: it never records a human decision, and an answered ask cannot be
67
67
  resolved. A human may reply to an open or answered ask; so may you, e.g. after finding the answer — use `reply_to_ask` on
68
68
  `dispatch_comment` (mutually exclusive with `reply_to`).
69
69
  A review comment you opened has its own closer, `dispatch_resolve_comment` — see
@@ -123,6 +123,10 @@ paragraph you replaced, which keeps that paragraph's id; only when no paragraph
123
123
  the old block's place is it a `delete` of the old block and then an `insert` of the new one, anchored on the
124
124
  block before or after it. The delete is what costs the id (below); a typed block keeps its id when the insert
125
125
  carries it, which works only in that order, because an insert carrying an id the document still holds is refused.
126
+ The tools refuse a `delete` or `retype` that would take out an `ask` block whose ask is still open, delete-then-insert included:
127
+ reword it with `replace`, relocate it with `move`, or change its question, options, urgency or `multiple` with `dispatch_edit_ask` if you asked it. A single
128
+ batch that moves an open block out of a container and then deletes the container is refused; make them two calls (the check reads
129
+ the document as it stood before the batch).
126
130
  Then read the document back with
127
131
  `dispatch_doc_read` and read the passage and its neighbours, not a grep for the words you added: an empty
128
132
  `with` deletes the matched text on purpose, so a `replace` whose `with` you meant to fill
@@ -137,8 +141,8 @@ A `delete` whose `find` is a block's entire text removes the block itself — th
137
141
  a list emptied of every item disappears with it; a partial match keeps the block with its remaining text. Deleting the text of a bullet
138
142
  that holds a nested list hoists that list's items into the bullet's place (as an outliner does); a bullet with any other content
139
143
  (paragraphs, code, tables) is refused with `INVALID_OP` naming `delete {block:"<item id>"}`, which removes the item with its content.
140
- `delete` with `block` removes any block by id (paragraph, heading, list, list item, table, or typed block; deleting an open `ask` block
141
- retracts its ask, while an answered one keeps its answer as the record), and `move` with `block` relocates one, keeping its id and
144
+ `delete` with `block` removes any block by id (paragraph, heading, list, list item, table, or typed block; an answered `ask` block keeps
145
+ its answer as the record, and a resolved one, which carries no answer, its resolution), and `move` with `block` relocates one, keeping its id and
142
146
  attributes — a moved `ask` keeps its ask and answer. **A block loses its id only when it is removed**, and its anchors go with it:
143
147
  `delete` by text or by id removes the block and any container it empties; `delete_row` / `delete_column` remove their cells' ids,
144
148
  which is why they are refused while an open ask or unresolved comment sits on them; and a `move` that takes the last block out of a
@@ -1,9 +1,9 @@
1
1
  # Documents: typed blocks, comments, suggestions, and artifacts
2
2
 
3
3
  `skill://dispatch` sends you here when you write a typed block (an `:::ask` or a callout), comment on
4
- or suggest a change to a document, upload an artifact, or get `DOC_SCHEMA`, `INVALID_ASK_BLOCK` or
5
- `DOC_SERVICE_UNAVAILABLE` back. Changing a document's text, tables included, is
6
- [Editing a document](skill://dispatch/references/document-edits.md).
4
+ or suggest a change to a document, upload an artifact, request a document's approval, or get
5
+ `DOC_SCHEMA`, `INVALID_ASK_BLOCK` or `DOC_SERVICE_UNAVAILABLE` back. Changing a document's text,
6
+ tables included, is [Editing a document](skill://dispatch/references/document-edits.md).
7
7
 
8
8
  ## Typed blocks
9
9
 
@@ -23,7 +23,10 @@ names one block, so an insert, upload or suggestion whose markdown names an id t
23
23
  outside the text it replaces is refused naming the id: `INVALID_OP` for an insert,
24
24
  `INVALID_MARKDOWN` for any other write. To rewrite such a block whole, `delete` it and then
25
25
  `insert` the new one carrying its id, anchored on the block before or after it, in that order and
26
- in one batch: an insert carrying an id the document still holds is refused.
26
+ in one batch: an insert carrying an id the document still holds is refused. An `ask` block whose ask
27
+ is still open cannot be rewritten that way: the tools refuse the `delete`, so reword it with
28
+ `replace`, relocate it with `move`, or change its question, options, urgency or `multiple` with
29
+ `dispatch_edit_ask` if you asked it ([Editing a document](skill://dispatch/references/document-edits.md)).
27
30
 
28
31
  Use only the type names, content rule, attributes, and enum values returned by the schema. Values are
29
32
  quoted: `:::callout{kind="warning" title="Risk"}`. Do not write Pandoc-style `::: {.callout}`, leaf
@@ -141,6 +144,26 @@ delimiter rows as many. Blank cells past the width are dropped, on every path. A
141
144
  a version answers `INVALID_MARKDOWN`, an insert of blocks `INVALID_OP` on `markdown`, and an insert
142
145
  of bare table rows `TABLE_WIDTH`, which names the cell counts only.
143
146
 
147
+ ## Approval requests
148
+
149
+ ```
150
+ dispatch_request_approval({ issue?, project?, artifact?, summary })
151
+ ```
152
+
153
+ The call opens an approval ask with the options `Approve` and `Request changes`, its question
154
+ "Approve spec.md (version N)?" followed by `summary`, where N is the document's latest version. It
155
+ is refused, with nothing sent, while that version holds a decision block open, and the refusal
156
+ names each block and its ask. An answer or a `dispatch_resolve_ask` closes the ask at once but
157
+ reaches a version only when the document settles, about two seconds later, or with your next
158
+ `dispatch_doc_edit`: fold the answer into the text (or, for a waiver, write the human's decision
159
+ in) and then request. A block written in the last few seconds counts as open before Dispatch has
160
+ opened its ask. A repeat at the same version returns the open request unchanged. A new version
161
+ retracts an open request for an older one, and its `ask.resolved` reaches you: request again for
162
+ the new version once its blocks are settled. The answer reaches you as `artifact.approved` or
163
+ `artifact.changes_requested` with the pinned `version`; `changes_requested` carries the reason,
164
+ which is your next piece of work. `dispatch_read` and `dispatch_doc_read` show the document's
165
+ approval state; `stale` means it was approved and then edited.
166
+
144
167
  ## A document that is reloading
145
168
 
146
169
  These calls can answer `DOC_SERVICE_UNAVAILABLE` (HTTP 503), because each writes a document inside its
@@ -84,12 +84,12 @@ Wave releases, child closures, and your own status are visible from the issue tr
84
84
  handoffs; do not narrate them into the spec or a `dispatch_message`. A blocker only Sami can
85
85
  clear is a `dispatch_ask`.
86
86
 
87
- The issue's primary document **is** the root specification. Extend it in place — a new version
88
- that keeps the human's own text and adds Summary, New since we talked, the adoption/decomposition
89
- and waves, acceptance criteria, and the integration test — never a second "spec" artifact beside
87
+ The issue's primary document **is** the root specification. Extend it in place: a new version
88
+ that keeps the human's own text and grows the design (the adoption or decomposition and its
89
+ waves, how each outcome is proven, and the integration test), each open question a
90
+ [decision block](../dispatch/SKILL.md#decision-blocks). Never post a second "spec" artifact beside
90
91
  it (`dispatch_artifact` with the primary document's name replaces the human's document; do not do
91
- that). Both readers described in [Writing for the human](../dispatch/SKILL.md#writing-for-the-human)
92
- must be able to follow it.
92
+ that).
93
93
  The design gate runs only when the "Design gate policy" line at the end of your system prompt
94
94
  says `gates.design: root-issues`. When it says `gates.design: off`, write the spec and continue
95
95
  to section 2 with no approval step at all: do not request approval, do not register a gate, and
@@ -99,7 +99,11 @@ sequence **before any Legion-role spawn**, including a sub-architect:
99
99
 
100
100
  ```text
101
101
  dispatch_doc_edit({ issue: "<root issue>", ... }) // extend the primary document in place
102
- result = dispatch_request_approval({ issue: "<root issue>" }) // the primary document by default
102
+ // then settle its decision blocks (below)
103
+ result = dispatch_request_approval({
104
+ issue: "<root issue>", // the primary document by default
105
+ summary: "<what the tree will do that the human hasn't already agreed to>",
106
+ })
103
107
  legion({
104
108
  op: "register_gate",
105
109
  issue: "<root issue>",
@@ -108,33 +112,41 @@ legion({
108
112
  })
109
113
  ```
110
114
 
115
+ The decision blocks come first: settle every one as
116
+ [Approval of a spec](../dispatch/SKILL.md#approval-of-a-spec) says before you request approval;
117
+ `dispatch_request_approval` refuses while one is open. Each answer reaches you, since you follow
118
+ every ask you open. `summary` says in one to three sentences what the tree will do that the human
119
+ hasn't already agreed to.
120
+
111
121
  `dispatch_request_approval` opens a system question on the document with the fixed options
112
122
  `Approve` and `Request changes`; a human answers it from the Inbox or approves from the
113
123
  document's own header. Never open a `dispatch_ask` with an `Approve` option yourself: an
114
124
  ordinary question is not a gate and the daemon ignores its answer. Copy `artifactId` and
115
125
  `version` from the result of `dispatch_request_approval` — its text reads "Approval requested for
116
- spec.md (document id <UUID>) at version <N>" and its `details.artifact` / `details.version` carry
117
- the same two values. The document id is never the slug or file name you passed in (`spec`,
118
- `spec.md`): the daemon recognizes the document's approval events by that id, and both the
119
- `legion` tool and the daemon refuse a value that is not a UUID. Calling
120
- `dispatch_request_approval` again while a request is open returns the same open request, so it
121
- is safe to repeat. If its text instead reads "spec.md (document id <UUID>) is already approved at
122
- version <N>" — a human approved from the document header before you asked — still call
123
- `register_gate` with that id and version: the daemon reads the approval from Dispatch as it
126
+ spec.md (document id <UUID>) at version <N> (ask <id>)", followed by the question the human's
127
+ Inbox shows, and its `details.artifact` / `details.version` carry the same two values. The
128
+ document id is never the slug or file name you passed in (`spec`, `spec.md`): the daemon
129
+ recognizes the document's approval events by that id, and both the `legion` tool and the daemon
130
+ refuse a value that is not a UUID. Calling `dispatch_request_approval` again at the version an
131
+ open request names returns that request unchanged, so it is safe to repeat; once the document has
132
+ a newer version, that request is retracted (its `ask.resolved` reaches you) and the call opens a
133
+ new one at the latest version. If its text instead reads "spec.md (document id <UUID>) is already
134
+ approved at version <N>" — a human approved from the document header before you asked — still
135
+ call `register_gate` with that id and version: the daemon reads the approval from Dispatch as it
124
136
  registers, opens the gate, and delivers `design-approved` at once. The same read covers a human
125
137
  who answers the question between your `dispatch_request_approval` and `register_gate` calls, so
126
138
  an approval is never lost to timing; you never approve anything yourself.
127
139
 
128
140
  Then park. Do not release a wave or spawn a Legion role until a later delivered wake shows
129
141
  `design-approved` on the root. On `design-changes-requested`, revise the spec (a new version of
130
- the primary document), call `dispatch_request_approval` again — it re-opens the request at the
131
- new version — and stay parked. Approval is pinned to the spec version: editing the root spec
132
- after approval closes the gate again with no wake (you made the edit, or the `artifact.version`
133
- event on your issue tells you), so call `dispatch_request_approval` again, and release no new
134
- wave and spawn no new role until the next `design-approved` arrives — work already in flight
135
- continues. Later waves, re-scopes, and integration-failure children that leave the root spec
136
- untouched need no new approval, and a child issue's spec is never gated: the root approval covers
137
- the tree.
142
+ the primary document), call `dispatch_request_approval` again with a `summary` of what the
143
+ revision proposes — it opens the request at the new version — and stay parked. Approval is
144
+ pinned to the spec version: editing the root spec after approval closes the gate again with no
145
+ wake (you made the edit, or the `artifact.version` event on your issue tells you), so call
146
+ `dispatch_request_approval` again with a `summary`, and release no new wave and spawn no new role
147
+ until the next `design-approved` arrives — work already in flight continues. Later waves,
148
+ re-scopes, and integration-failure children that leave the root spec untouched need no new
149
+ approval, and a child issue's spec is never gated: the root approval covers the tree.
138
150
 
139
151
  ## 2. Children in flight
140
152
 
@@ -307,7 +319,7 @@ active phase worker.
307
319
  | `children-complete` | Execute steps 3–4: parent integration verification; failures become a new child wave, success advances to review and retro. |
308
320
  | `child-reopened` | Treat the completion edge as reset. Reassess the reopened child and return the tree to children-in-flight; do not continue an already-started end-game. |
309
321
  | `design-approved` | Payload `{type:"design-approved"}`. A human approved the root spec document at its current version; the gate is open. Proceed to section 2. |
310
- | `design-changes-requested` | Payload `{type:"design-changes-requested", version, reason, author?}`. A human asked for changes to the root spec at `version`, for `reason`. Revise the spec, call `dispatch_request_approval` again, and stay parked; the gate is closed. |
322
+ | `design-changes-requested` | Payload `{type:"design-changes-requested", version, reason, author?}`. A human asked for changes to the root spec at `version`, for `reason`. Revise the spec, call `dispatch_request_approval` again with a `summary` of what the revision proposes, and stay parked; the gate is closed. |
311
323
  | `phase-complete` | Payload `{type:"phase-complete", issue, role, summary}`. May arrive live or via `catchup-overseer`'s `phaseCompletions`. Read the committed handoff for that phase, then spawn the next phase's owner, or `spawn_worker` on the same role again to resume it with corrections if the handoff shows unresolved gaps. A `reviewer` completion whose GitHub review is `CHANGES_REQUESTED` (the daemon returns the issue's Dispatch status to `in_progress` for this, on the reviewer's completion and again when you spawn the corrective implementer unless the daemon already knows the issue is `in_progress`) means `spawn_worker` the **implementer** again with the review findings — thread URLs and blocking items — as its task, then route back through tester and reviewer in order; never `spawn_worker` the reviewer directly off this wake and never proceed to retro on this verdict. A reviewer completion with an `APPROVED` review proceeds to retro (step 5). A `reviewer` completion after a conflict-forced rebase whose review body names an unchanged fingerprint is a confirmation, not a round: if retro already completed, `spawn_worker` the merger; otherwise resume the step you were on. An `implementer` completion that follows the merge is its production report: read the record on the pull request and the issue, then run step 7 — the issue is already at `retro`, the daemon writes no status for this completion, and you set `done` yourself. A `tester` completion whose handoff carries `implementerProof.verdict: "rejected"`, or a failure naming the production-like proof, goes back to the **implementer** with that finding — never forward to the reviewer, and never by supplying the proof from another role. A worker that reports no surface reaches the changed path gets a child issue in this tree (infrastructure, tooling, or a skill) and a resume once it lands; that report is never a reason to advance the phase. |
312
324
  | `worker-queued` | Payload `{type:"worker-queued", issue, role}`. This role's task is queued for promotion — either the deployment's worker cap is full, or the live worker acknowledged the task without starting a turn and the daemon is retrying it (counted; the worker is replaced after three such failures, still with the same task). Do not respawn or retry — wait for `worker-started`. `legion state` shows the queue (`workerAdmission.queue`: role token, issue, role, kind, and the time the task was first queued — never the task text); read it before re-sending. A `spawn_worker` identical to the queued task changes nothing and is not announced again. Different text replaces the queued task silently in the same FIFO slot and retains its original queue time. A `spawn_worker` that fails with "got no response in 3 attempts" was already retried by the plugin under one request id and may still have reached the daemon: read the queue and the role's claim in `legion state` before sending it again. |
313
325
  | `worker-started` | Payload `{type:"worker-started", issue, role}`. A previously queued role has been promoted and is now running. Treat it exactly as a normal spawn: resume tracking that role's live session. |
@@ -331,9 +343,9 @@ Sami directly with `dispatch_ask` the same way. Do not create a wait loop for an
331
343
  source.
332
344
 
333
345
  Never yield while waiting on a human. A human is waiting on you only where an open ask sits
334
- in their inbox, so open it — `dispatch_ask`, or `dispatch_request_approval` for the spec
335
- gate — before you stop. Otherwise proceed: proceeding is the default, and a stop that waits
336
- on nobody stalls the tree until someone notices.
346
+ in their inbox, so open it before you stop: `dispatch_ask`, a decision block in the spec, or
347
+ `dispatch_request_approval` for the spec gate. Otherwise proceed: proceeding is the default, and
348
+ a stop that waits on nobody stalls the tree until someone notices.
337
349
 
338
350
  ## Architecture components
339
351
 
@@ -65,7 +65,13 @@ before step 3. The design gate is not a substitute for review and retro.
65
65
  ## Durable outputs
66
66
 
67
67
  Write the integrated learning as one or more discoverable documents under `docs/solutions/`.
68
- Organize by reusable topic rather than by pull request. Each document uses this front matter:
68
+ Organize by reusable topic rather than by pull request. Search `docs/solutions/` for the topic
69
+ first: when a document already states the rule, update it in place (sharpen the rule, add this
70
+ issue and pull request to `related_issues`) rather than writing a sibling; when the new learning
71
+ replaces an old document, set the old one's `status: superseded` and add
72
+ `superseded_by: docs/solutions/<path>.md`. Open each document with the rule in a few imperative
73
+ lines; the incident that taught it goes in an Evidence section below, never in the rule. Each
74
+ document uses this front matter:
69
75
 
70
76
  ```yaml
71
77
  ---
@@ -28,10 +28,10 @@ left open <thread URL> — newest reply by <login> is not its opener's or the Le
28
28
  <verdict>. (omitted entirely on a docs-only PR — there is no code for either pass, so neither runs)
29
29
 
30
30
  **E2E (implementer):** <surface> — ran `<command or run id>`, observed <result>, at head <sha>.
31
- Negative control: <deliberately broken input> → <refusal or failure observed>.
31
+ Negative control: <deliberately broken input or call> → <refusal or failure observed>.
32
32
 
33
33
  **E2E (tester):** <surface> — ran `<command or run id>`, observed <result>, at head <sha>.
34
- Negative control: <deliberately broken input> → <refusal or failure observed>.
34
+ Negative control: <deliberately broken input or call> → <refusal or failure observed>.
35
35
  Verified the implementer's proof by <re-running its command | driving the same surface independently>.
36
36
 
37
37
  **Production:** <what was checked in production, how, what was observed> — merge commit <sha>.
@@ -46,7 +46,7 @@ Verified the implementer's proof by <re-running its command | driving the same s
46
46
 
47
47
  **A proof** is the changed behaviour exercised on the surface a user reaches it through, recorded
48
48
  as the exact command or run id, what was observed, the head SHA, and one negative control —
49
- a deliberately broken input and the refusal or failure observed. The surface is
49
+ a deliberately broken input or call and the refusal or failure observed. The surface is
50
50
  **production-like** — the repository's real-process test harness and fixtures, a sandbox
51
51
  repository, a real browser, a devN stack, staging, or a local stack with real migrations, one that
52
52
  has the resource the change touches — and each `E2E` line carries a **link** to that run,
@@ -56,6 +56,32 @@ The codebase might gate features behind feature flags or internal-only checks. D
56
56
  ## Intended Breakage Guidelines
57
57
  If a high-risk effect is an intentional, well-constrained change, do not report it as a defect. Report it when the scope or consequences appear unclear, including when a safeguard or feature gate is removed.
58
58
 
59
+ ## Claims in the PR body
60
+
61
+ A safety or correctness claim written in a PR body is a claim like any other, and the only reader who catches a wrong one is the reader told to ATTACK it. Verifying reviewers read the code against the claim and pass; that is what they are for. Attack the body's claims, not only its diff.
62
+
63
+ Two shapes to attack first:
64
+
65
+ - **Neutralization ORDER, not coverage.** Any pipeline that sanitizes and then edits can create what it sanitized. The test is not "did it neutralize everything" but "can any later pass CREATE what was being neutralized".
66
+ - **A severity resting on a third party's formatting is a dependency, not a mitigation.** Rate it as the bet it is, and fix rather than disclose.
67
+
68
+ ## Security Guidelines
69
+
70
+ For each row whose surface the diff touches, answer with a file:line citation. End your report with one `Security:` line: each touched row's tag and its answer with file:line, or `Security: no sensitive surface in this diff.` when the diff touches none.
71
+
72
+ | Tag | If the diff touches… | Answer, with file:line |
73
+ | --- | --- | --- |
74
+ | `authz` | an authorization or refusal check, or a new route, command, or tool | who may call it, who may not, where the diff enforces that, and what the unauthorized caller gets |
75
+ | `secret` | a token, grant, secret file, credential helper, or its lifetime | what widened: who can read it, for how long, in which process |
76
+ | `untrusted-input` | a subprocess, argv, path, template, or query built from text an outside party controls (an issue body, a PR comment, a webhook payload, model output) | the boundary that neutralizes it, and whether any later pass can re-create what was neutralized (order, not coverage) |
77
+ | `prompt` | a prompt that embeds untrusted text into an agent's instructions | what delimits the untrusted region, and what the agent may do if it obeys that text (OWASP LLM01 prompt injection, LLM06 excessive agency) |
78
+ | `supply-chain` | a dependency, lockfile, base image, or GitHub Action | the version, the pin (a digest or a SHA checked against its tag), and the permissions the workflow runs with |
79
+ | `sandbox` | a sandbox or pod manifest, a capability, or a network policy | which isolation property changed, and against whom |
80
+ | `agent-def` | an agent definition, skill, role prompt, `.omp/` config, or `AGENTS.md` | whether this diff can steer its own reviewers, and why this edit is trustworthy anyway |
81
+ | `transport` | TLS/certificate verification, a signature, HMAC, or randomness source, or an unbounded read/write sized by untrusted input | what changed, the check or bound it relies on, and the ASVS V11/V12 identifier it maps to |
82
+
83
+ Cite an OWASP ASVS v5.0.0 identifier where one applies (`v5.0.0-1.2.5` style). A security finding with no stated exploit path is not a finding: call it hardening and rank it Minor. A security finding that states an exploit path is always its own finding at its real priority, never folded into hardening. Report at most two hardening items, ranked, and fold the rest into one hardening paragraph. Start each security finding with its row's tag, `Security[<tag>]:`, so it can be told from the others and counted by row.
84
+
59
85
  ## Over-reporting Guidelines
60
86
  If you report issues as High priority when they are not in fact high priority / meaningful issues, devs will lose trust in you and stop listening to you over time.
61
87
  Never misreport priority or importance. Trace issues end to end and report only what the evidence supports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "5.24.6",
3
+ "version": "5.26.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [