@sjawhar/opencode-legion-envoy 1.42.4 → 1.44.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.
@@ -13816,6 +13816,20 @@ function documentOwnerValidation(requireArtifact, alwaysRequireArtifact = false)
13816
13816
  message: alwaysRequireArtifact ? "Exactly one of issue and project is required; artifact or ref must name the document." : "Exactly one of issue and project is required; with project, artifact names the document."
13817
13817
  };
13818
13818
  }
13819
+ var documentEditValidation = {
13820
+ check: (value) => {
13821
+ if (!documentOwnerValidation(true, true).check(value))
13822
+ return false;
13823
+ const input = value;
13824
+ if (input.precondition === undefined)
13825
+ return true;
13826
+ if (typeof input.precondition !== "object" || input.precondition === null)
13827
+ return false;
13828
+ const precondition = input.precondition;
13829
+ return typeof precondition.document === "string" !== Array.isArray(precondition.blocks);
13830
+ },
13831
+ message: "Exactly one of issue and project is required; artifact or ref must name the document. A precondition selects exactly one of document or blocks."
13832
+ };
13819
13833
  var commentOwner = documentOwnerValidation(true);
13820
13834
  var commentValidation = {
13821
13835
  check: (value) => {
@@ -13857,7 +13871,15 @@ var ISSUE_STATUSES = [
13857
13871
  "retro",
13858
13872
  "done"
13859
13873
  ];
13860
- var DOC_EDIT_OPS = ["replace", "delete", "insert", "retype", "move"];
13874
+ var DOC_EDIT_OPS = [
13875
+ "replace",
13876
+ "delete",
13877
+ "insert",
13878
+ "retype",
13879
+ "move",
13880
+ "delete_row",
13881
+ "delete_column"
13882
+ ];
13861
13883
  var dispatchToolSpecs = [
13862
13884
  {
13863
13885
  name: "dispatch_issue",
@@ -14035,9 +14057,10 @@ var dispatchToolSpecs = [
14035
14057
  example: {
14036
14058
  issue: "DSP-1",
14037
14059
  artifact: "spec",
14038
- ops: [{ op: "replace", find: "old", with: "new" }]
14060
+ ops: [{ op: "delete_column", block: "table-123", index: 1 }],
14061
+ precondition: { blocks: [{ id: "table-123", token: "sha256:current-table-token" }] }
14039
14062
  },
14040
- 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, and delete or move a whole block by its id. " + "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; a leading '# ' matches a heading. replace is inline: with is the new text of the matched span, so a leading list or heading marker stays literal text. " + "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. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids are the #id of a typed block or a row of GET /api/v1/artifacts/{id}/blocks. ' + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
14063
+ 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; a leading '# ' matches a heading. replace is inline: with is the new text of the matched span, so a leading list or heading marker stays literal text. " + "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 spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
14041
14064
  arguments: (z) => ({
14042
14065
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
14043
14066
  project: z.string().describe("Project key owning the document.").optional(),
@@ -14051,18 +14074,26 @@ var dispatchToolSpecs = [
14051
14074
  markdown: z.string().describe("Markdown to insert.").optional(),
14052
14075
  after: z.string().describe(`Insert or move after this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>", "block:<id>".`).optional(),
14053
14076
  before: z.string().describe(`Insert or move before this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>", "block:<id>".`).optional(),
14054
- block: z.string().describe("Block id for retype, delete, or move: the #id of a typed block, or an id from GET /api/v1/artifacts/{id}/blocks.").optional(),
14077
+ block: z.string().describe("Block id for retype, delete, move, delete_row, or delete_column: the #id of a typed block, or an id from GET /api/v1/artifacts/{id}/blocks.").optional(),
14078
+ index: z.number({ int: true, min: 0 }).describe("Zero-based row or column index for delete_row or delete_column.").optional(),
14055
14079
  type: z.string().describe("Typed block name for retype.").optional(),
14056
14080
  attributes: z.unknown().describe("Typed block attributes for retype.").optional()
14057
14081
  })).describe("Flat tagged edits; the server validates fields required for each operation."),
14082
+ precondition: z.object({
14083
+ document: z.string({ min: 1 }).describe("Token for the exact canonical document returned by dispatch_doc_read.").optional(),
14084
+ blocks: z.array(z.object({
14085
+ id: z.string({ min: 1 }).describe("Stable block id from GET /api/v1/artifacts/{id}/blocks."),
14086
+ token: z.string({ min: 1 }).describe("That block's full-state token, including inline marks.")
14087
+ }), { min: 1 }).describe("Every content block this batch changes, each with the token returned by /blocks.").optional()
14088
+ }).describe("Optional optimistic-concurrency guard; select exactly one of document or blocks.").optional(),
14058
14089
  summary: z.string().describe("Optional named-version summary.").optional()
14059
14090
  }),
14060
- validation: documentOwnerValidation(true, true)
14091
+ validation: documentEditValidation
14061
14092
  },
14062
14093
  {
14063
14094
  name: "dispatch_doc_read",
14064
14095
  example: { issue: "DSP-1" },
14065
- description: "Read a live document or a named document version. Do not use it for issue status, asks, or events; " + "use dispatch_read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + OWNER_REFERENCE,
14096
+ description: "Read a live document or a named document version. Do not use it for issue status, asks, or events; " + "use dispatch_read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch_doc_edit precondition; use /blocks for per-block tokens. " + OWNER_REFERENCE,
14066
14097
  arguments: (z) => ({
14067
14098
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
14068
14099
  project: z.string().describe("Project key owning the document.").optional(),
@@ -14287,6 +14318,14 @@ var reviewSchema = baseHandoffSchema.extend({
14287
14318
  verdict: _enum2(["approved", "changes_requested"]).optional(),
14288
14319
  keyFindings: array(object({ severity: string2(), file: string2(), description: string2() }).passthrough()).optional()
14289
14320
  });
14321
+ var nonEmptySkillList = array(string2().trim().min(1)).min(1);
14322
+ var planWriteSchema = planSchema.extend({
14323
+ requiredSkills: object({
14324
+ implement: nonEmptySkillList,
14325
+ test: nonEmptySkillList,
14326
+ review: nonEmptySkillList
14327
+ }).passthrough()
14328
+ });
14290
14329
  var phaseHandoffSchema = discriminatedUnion("phase", [
14291
14330
  architectSchema,
14292
14331
  planSchema,
@@ -14910,12 +14949,16 @@ class DispatchServiceError extends Error {
14910
14949
  code;
14911
14950
  status;
14912
14951
  candidates;
14952
+ current;
14953
+ mismatches;
14913
14954
  name = "DispatchServiceError";
14914
- constructor(code, status, message, candidates) {
14955
+ constructor(code, status, message, candidates, current, mismatches) {
14915
14956
  super(message);
14916
14957
  this.code = code;
14917
14958
  this.status = status;
14918
14959
  this.candidates = candidates;
14960
+ this.current = current;
14961
+ this.mismatches = mismatches;
14919
14962
  }
14920
14963
  }
14921
14964
  function asErrorShape(value) {
@@ -15227,7 +15270,7 @@ class DispatchClient {
15227
15270
  }
15228
15271
  if (!response.ok) {
15229
15272
  const error = asErrorShape(payload);
15230
- throw new DispatchServiceError(error.code ?? `HTTP_${response.status}`, response.status, error.error ?? (typeof payload === "string" && payload ? payload : response.statusText), error.candidates);
15273
+ throw new DispatchServiceError(error.code ?? `HTTP_${response.status}`, response.status, error.error ?? (typeof payload === "string" && payload ? payload : response.statusText), error.candidates, error.current, error.mismatches);
15231
15274
  }
15232
15275
  return payload;
15233
15276
  }
@@ -16577,9 +16620,12 @@ ${followsAsk(askOwner)}`,
16577
16620
  const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
16578
16621
  const ops = args.ops;
16579
16622
  const summary = optionalString(args, "summary");
16623
+ const { precondition: rawPrecondition } = args;
16624
+ const precondition = rawPrecondition;
16580
16625
  const edited = await client.docEdit(resolved.artifact.id, {
16581
16626
  ops,
16582
16627
  ...summary === undefined ? {} : { summary },
16628
+ ...precondition === undefined ? {} : { precondition },
16583
16629
  actor
16584
16630
  });
16585
16631
  const retyped = ops.filter((operation) => operation.op === "retype").length;
@@ -16607,6 +16653,7 @@ ${followsAsk(askOwner)}`,
16607
16653
  const marks = marksResult.value;
16608
16654
  const approval = approvalLine(resolved.artifact);
16609
16655
  const trailer = [
16656
+ ..."token" in document && document.token !== undefined ? [`Document token: ${document.token}`] : [],
16610
16657
  ...marks.length === 0 ? [] : [`Open anchored asks/comments: ${marks.join(", ")}`],
16611
16658
  ...approval === undefined ? [] : [approval]
16612
16659
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.42.4",
3
+ "version": "1.44.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
package/skills/AGENTS.md CHANGED
@@ -4,44 +4,17 @@ Legion skills guide the architect and its sequential phase workers in a shared i
4
4
  They are Markdown instructions loaded by Oh My Pi sessions; the daemon and OMP extension own
5
5
  event intake, process lifecycle, credentials, and role delivery.
6
6
 
7
- ## Structure
8
-
9
- ```
10
- skills/
11
- ├── dispatch/ # Writing specs, asks, comments, and artifacts on native Dispatch
12
- ├── envoy/ # Envoy subscriptions, agent-to-agent messages, and topic formats
13
- ├── legion-architect/ # Tree ownership, decomposition, gates, and scheduling
14
- ├── legion-controller/ # Derived-verdict control-plane operation
15
- ├── legion-oracle/ # Repository-grounded research
16
- ├── legion-retro/ # Post-review retrospective
17
- └── legion-worker/ # Sequential architect, plan, implement, test, and review phases
18
- ```
19
-
20
- ## Phase workers
21
-
22
- The extension supplies a phase worker with its issue, workspace, role token, and structured
23
- output schema. The worker claims its supplied role, works only on its phase artifact, and
24
- returns that schema to the architect. It writes the same phase-specific payload to
25
- `.legion/<phase>.json`, verifies it exists, and commits the handoff before reporting completion.
26
- The committed predecessor handoff wins after revival or re-creation.
27
-
28
- Workers do not run a controller loop or mutate lifecycle labels. Workers coordinate
29
- lifecycle, scope, and cross-phase decisions with the owning architect by `envoy_publish` to its
30
- role topic, sending the verified observation and decision needed (`hub` reaches only subagents
31
- inside the worker's own process). A worker may call the native `dispatch_*` tools directly for a
32
- durable human question; replies come back to the worker's own session.
33
-
34
- ## Durable artifacts
35
-
36
- Phase handoffs are committed in lifecycle order: architect, plan, implement, test, and review.
37
- Only the implementer pushes them: it and the merger act as the code-writing GitHub App, while
38
- the planner, tester, reviewer, and architects act as the review App (`appRoleForLegionRole`,
39
- `packages/daemon/src/daemon/github-apps.ts`), which holds no `contents` permission — their
40
- handoff commits stay on the shared workspace's issue branch and ride the implementer's next push.
41
- A clean review ends with the `.legion/` deletion pushed by the implementer at the reviewer's
42
- direction, which the reviewer then approves; retro records its learning in
43
- `docs/solutions/` and writes no handoff. GitHub comments and reviews carry the required Legion
44
- footer so the daemon can attribute artifacts to their worker session.
45
- The implement handoff carries the implementer's own production-like proof and the test handoff the
46
- tester's verdict on it plus the tester's own; `legion handoff write` refuses a payload the phase's
47
- schema rejects and names the field. Retro's message goes to the Dispatch issue (`dispatch_message`), never a GitHub issue.
7
+ | Skill | Who reads it | What it owns |
8
+ | --- | --- | --- |
9
+ | `dispatch/` | every role, and any session writing to Dispatch | specs, asks, comments, artifacts, and messages on native Dispatch |
10
+ | `envoy/` | every role | subscriptions, agent-to-agent messages, and topic formats |
11
+ | `legion-architect/` | root and sub-architects | tree ownership, decomposition, waves, gates, integration, sign-off |
12
+ | `legion-controller/` | the controller root process | wake routing, backlog admission, escalation |
13
+ | `legion-oracle/` | any role doing research | repository-grounded research |
14
+ | `legion-retro/` | the implementer, at retro | the pre-merge retrospective and its Dispatch message |
15
+ | `legion-worker/` | planner, implementer, tester, reviewer, merger | the phase contracts: handoffs, GitHub identity, PR body and READY discipline, the merge-gate order |
16
+
17
+ 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.
18
+ The text a worker boots with (its role prompt) lives in `packages/pi-envoy/roles/` and is
19
+ composed per role in `packages/daemon/src/daemon/processes.ts`; `packages/pi-envoy/roles/roles.test.ts`
20
+ holds the structural rules for those parts.
@@ -381,18 +381,19 @@ Read the current document before changing it:
381
381
  ```ts
382
382
  dispatch_doc_read({ issue?, project?, artifact?, version?, ref? })
383
383
  ```
384
- It returns live or versioned markdown with open marks. `issue` with an omitted `artifact` reads the issue specification; a project needs
385
- `artifact`; and a `dispatch://PROJECT/artifact/<document-ref>` ref supplies both, where `document-ref` is the id, slug, or filename.
384
+ It returns live or versioned markdown with open marks. A live read ends with a document token; `issue` with an
385
+ omitted `artifact` reads the issue specification; a project needs `artifact`; and a
386
+ `dispatch://PROJECT/artifact/<document-ref>` ref supplies both, where `document-ref` is the id, slug, or filename.
386
387
 
387
388
  ```ts
388
- dispatch_doc_edit({ issue?, project?, artifact, ops, summary? })
389
+ dispatch_doc_edit({ issue?, project?, artifact, ops, precondition?, summary? })
389
390
  ```
390
391
  It returns issue or project-document owner details plus `applied` and optional `version`. `ops` is an array of this
391
392
  exact `EditOp` shape:
392
393
 
393
394
  ```ts
394
395
  type EditOp = {
395
- op: "replace" | "delete" | "insert" | "retype" | "move";
396
+ op: "replace" | "delete" | "insert" | "retype" | "move" | "delete_row" | "delete_column";
396
397
  find?: string;
397
398
  with?: string;
398
399
  occurrence?: number;
@@ -400,6 +401,7 @@ type EditOp = {
400
401
  after?: string;
401
402
  before?: string;
402
403
  block?: string;
404
+ index?: number;
403
405
  type?: string;
404
406
  attributes?: Record<string, unknown>;
405
407
  };
@@ -411,18 +413,24 @@ anchor is its cell text. Quote code-block contents without their Markdown fences
411
413
  within one textblock; split changes that span separate blocks into separate operations.
412
414
 
413
415
  `replace` requires `find` and `with`; `delete` requires `find` or `block`; `insert` requires `markdown` and exactly one of `after` or
414
- `before`; `move` requires `block` and exactly one of `after` or `before`. An insert or move anchor is a quote, `"start"`, `"end"`,
415
- `"heading:Title"`, or `"block:<id>"`. Ordinary inserts create a sibling block before or after the quote, heading, or block's enclosing
416
- document block, and a move lands the block at that same boundary; `"start"` and `"end"` select the document edges. At a table-cell
417
- quote, a body-row fragment (no header or delimiter rows) extends that table before or after the matched row instead; short rows are
418
- padded, wider rows are rejected, and deleting a cell's quoted text removes only that text. A `find` or quote anchor tolerates inline
419
- Markdown (`**bold**`, `` `code` ``) and a leading `# ` selects a heading by its text; a miss names the three nearest blocks so the next
420
- quote lands.
421
-
422
- `replace` is inline: `with` is the new text of the matched span inside its block, so a leading list or heading marker (`4. Design`,
423
- `# Title`) stays literal text and never turns the block into a list or heading; `with` that forms more than one paragraph is rejected
424
- (`INVALID_OP` on `with`) — delete the block and insert new blocks instead. Use zero-based `occurrence` for a repeated target; re-read a
425
- missing or ambiguous target before retrying. Pass `summary` to name the version when recording a decision.
416
+ `before`; `move` requires `block` and exactly one of `after` or `before`; and `delete_row` / `delete_column` each require a table
417
+ `block` plus a zero-based `index`. An insert or move anchor is a quote, `"start"`, `"end"`, `"heading:Title"`, or `"block:<id>"`.
418
+ Ordinary inserts create a sibling block before or after the quote, heading, or block's enclosing document block, and a move lands the
419
+ block at that same boundary; `"start"` and `"end"` select the document edges. At a table-cell quote, a body-row fragment (no header or
420
+ delimiter rows) extends that table before or after the matched row instead; short rows are padded, wider rows are rejected, and deleting
421
+ a cell's quoted text removes only that text. `delete_row` / `delete_column` instead mutate their named table in place, keeping the
422
+ table's block id. A row index includes the header: row `0` is the header and its deletion promotes the first body row. The last body
423
+ row and any row's last column cannot be deleted. An index is required. A missing, non-integer, negative, or out-of-range index is
424
+ `INVALID_OP` on `index`, naming the supplied value and the table's actual dimensions before making any change. Markdown parsing
425
+ canonicalizes short ragged rows by padding missing cells, so column deletion preserves every non-selected cell in the canonical table.
426
+ `GET /api/v1/artifacts/<artifact UUID>/blocks` reports a table's own references plus its descendant cell anchors. A row or column
427
+ deletion that would remove an open ask or unresolved comment anchor is `INVALID_OP` on `index`, naming the axis and anchor ids;
428
+ answered asks and resolved comments are history and do not block it. A `find` or quote anchor tolerates inline Markdown
429
+ (`**bold**`, `` `code` ``) and a leading `# ` selects a heading by its text; a miss names the three nearest blocks so the next quote
430
+ lands. `replace` is inline: `with` is the new text of the matched span inside its block, so a leading list or heading
431
+ marker (`4. Design`, `# Title`) stays literal text and never turns the block into a list or heading; `with` that forms more than one
432
+ paragraph is rejected (`INVALID_OP` on `with`) — delete the block and insert new blocks instead. Use zero-based `occurrence` for a
433
+ repeated target; re-read a missing or ambiguous target before retrying. Pass `summary` to name the version when recording a decision.
426
434
 
427
435
  A `delete` whose `find` is a block's entire text removes the block itself — the bullet, paragraph, or heading, not just its words — and
428
436
  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
@@ -430,10 +438,25 @@ that holds a nested list hoists that list's items into the bullet's place (as an
430
438
  (paragraphs, code, tables) is refused with `INVALID_OP` naming `delete {block:"<item id>"}`, which removes the item with its content.
431
439
  `delete` with `block` removes any block by id (paragraph, heading, list, list item, table, or typed block; deleting an open `ask` block
432
440
  retracts its ask, while an answered one keeps its answer as the record), and `move` with `block` relocates one, keeping its id and
433
- attributes — a moved `ask` keeps its ask and answer. Block ids are the `#id` a typed block renders (`:::ask{#5467e5ce-…}`) and, for
434
- every block including untyped ones, the `id` rows of `GET /api/v1/artifacts/{id}/blocks` (or `/api/v1/issues/{key}/artifacts/{slug}/blocks`),
435
- each with its `type` and byte range in the canonical markdown. A move whose anchor lies inside the moved block, or a delete that would
436
- leave a typed block without the body its content rule requires, is `INVALID_OP` naming the field and the rule.
441
+ attributes — a moved `ask` keeps its ask and answer. Block ids are the `#id` a typed block renders
442
+ (`:::ask{#5467e5ce-…}`) and, for every block including untyped ones, the `id` rows from
443
+ `GET /api/v1/artifacts/<artifact UUID>/blocks` (or `/api/v1/issues/{key}/artifacts/{slug}/blocks`), each with its `type` and byte range
444
+ in canonical markdown; the UUID route does not accept a slug. A later operation in the same atomic batch that names a block removed by
445
+ an earlier `delete {block}` fails as `INVALID_OP` naming the earlier operation and the parent block that cascaded the removal. A move
446
+ whose anchor lies inside the moved block, or a delete that would leave a typed block without the body its content rule requires, is
447
+ `INVALID_OP` naming the field and the rule.
448
+
449
+ `GET /api/v1/artifacts/<artifact UUID>/blocks` includes a full-state `token` on every block, including
450
+ inline marks. To reject a stale edit, pass `precondition` with exactly one of
451
+ `{ document: "<token from dispatch_doc_read>" }` or
452
+ `{ blocks: [{ id: "<block id>", token: "<block token>" }] }`. The server resolves the whole batch before
453
+ mutation: a block guard must cover every content block it changes, or Dispatch returns
454
+ `400 INVALID_PRECONDITION` without applying anything. Use a document token for insert and move because they
455
+ depend on document order. A block token lets other sections change concurrently; a new anchored ask or comment
456
+ changes the relevant token. A stale guard returns `409 PRECONDITION_FAILED` with each mismatch and current
457
+ token; Dispatch applies no part of that batch. It is the hashline `#TAG` property applied to stable block ids,
458
+ not line numbers: canonical Markdown lines shift under concurrent edits and rendering changes, while block ids
459
+ survive moves and retyping.
437
460
 
438
461
  `retype` turns the paragraph or typed block with `block` into the named typed `type` in place. It keeps the
439
462
  block id, keeps a typed block's body, and uses `attributes` for client-owned typed attributes. Use it when