@sjawhar/pi-legion-envoy 5.24.5 → 5.25.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/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.5",
33614
+ version: "5.25.0",
33541
33615
  type: "module",
33542
33616
  omp: {
33543
33617
  extensions: [