@sjawhar/opencode-legion-envoy 0.35.0 → 0.36.1

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/README.md CHANGED
@@ -22,14 +22,16 @@ This package exposes:
22
22
  - `dispatch_doc_read`
23
23
  - `dispatch_artifact`
24
24
  - `dispatch_read`
25
-
26
- The native `dispatch_*` tools create and read Dispatch issues, asks, comments, documents, and
27
- artifacts. They are present when `dispatch.enabled` resolves a server URL and bearer token from
28
- envoy.json (`~/.config/opencode/envoy.json`, merged with `<repo>/.opencode/envoy.json`) or the
29
- `DISPATCH_URL` and `DISPATCH_TOKEN` environment variables; `dispatch.enabled: true` without
30
- `dispatch.serverUrl` targets `http://localhost:8766`. Each call fills the target issue from
31
- the session working directory, stamps it with the OpenCode session id and title, and stores
32
- `details.topic` as tool metadata so a successful mutation subscribes to that exact Dispatch topic.
25
+ - `dispatch_search`
26
+
27
+ The eleven native `dispatch_*` tools create and read Dispatch issues, asks, comments,
28
+ documents, and artifacts, or search all of them. They are present when `dispatch.enabled`
29
+ resolves a server URL and bearer token from envoy.json (`~/.config/opencode/envoy.json`, merged
30
+ with `<repo>/.opencode/envoy.json`) or the `DISPATCH_URL` and `DISPATCH_TOKEN` environment
31
+ variables; `dispatch.enabled: true` without `dispatch.serverUrl` targets `http://localhost:8766`.
32
+ Each issue-scoped call fills the target issue from the session working directory, stamps it with
33
+ the OpenCode session id and title, and stores a successful mutation's `details.topic` as tool
34
+ metadata so the host subscribes to that exact Dispatch topic.
33
35
 
34
36
  `dispatch_artifact` accepts exactly one upload source: a local `path`, or inline `content`.
35
37
  An architect can post a specification directly with
@@ -13597,6 +13597,56 @@ var ChildStatusEventPayloadSchema = object({
13597
13597
  from: string2().optional(),
13598
13598
  to: string2().optional()
13599
13599
  });
13600
+ // ../contracts/src/dispatch-snippet.ts
13601
+ var HTML_ENTITIES = [
13602
+ ["&lt;", "<"],
13603
+ ["&gt;", ">"],
13604
+ ["&#39;", "'"],
13605
+ ["&#34;", '"'],
13606
+ ["&amp;", "&"]
13607
+ ];
13608
+ function decodeEntities(text) {
13609
+ let decoded = text;
13610
+ for (const [entity, character] of HTML_ENTITIES) {
13611
+ decoded = decoded.replaceAll(entity, character);
13612
+ }
13613
+ return decoded;
13614
+ }
13615
+ function hasOnlyBalancedMarkers(snippet) {
13616
+ let marked = false;
13617
+ for (const marker of snippet.matchAll(/<mark>|<\/mark>/gu)) {
13618
+ if (marker[0] === "<mark>") {
13619
+ if (marked)
13620
+ return false;
13621
+ marked = true;
13622
+ } else {
13623
+ if (!marked)
13624
+ return false;
13625
+ marked = false;
13626
+ }
13627
+ }
13628
+ return !marked;
13629
+ }
13630
+ function snippetSegments(snippet) {
13631
+ if (!hasOnlyBalancedMarkers(snippet)) {
13632
+ return snippet === "" ? [] : [{ text: decodeEntities(snippet), mark: false }];
13633
+ }
13634
+ let mark = false;
13635
+ const segments = [];
13636
+ for (const part of snippet.split(/(<mark>|<\/mark>)/u)) {
13637
+ if (part === "<mark>") {
13638
+ mark = true;
13639
+ } else if (part === "</mark>") {
13640
+ mark = false;
13641
+ } else if (part !== "") {
13642
+ segments.push({ text: decodeEntities(part), mark });
13643
+ }
13644
+ }
13645
+ return segments;
13646
+ }
13647
+ function snippetText(snippet) {
13648
+ return snippetSegments(snippet).map(({ text, mark }) => mark ? `**${text}**` : text).join("");
13649
+ }
13600
13650
  // ../contracts/src/dispatch-tools.ts
13601
13651
  function dispatchToolSchema(spec, z, opts) {
13602
13652
  const shape = spec.arguments(z);
@@ -13618,12 +13668,13 @@ var DOC_EDIT_OPS = ["replace", "delete", "insert"];
13618
13668
  var dispatchToolSpecs = [
13619
13669
  {
13620
13670
  name: "dispatch_issue",
13621
- description: "Create a native Dispatch issue for newly tracked work. Do not use it when an existing issue " + `already covers the work; read or update that issue instead. ${ISSUE_REFERENCE}`,
13671
+ description: "Create a native Dispatch issue for newly tracked work. Search first with dispatch_search; if potentially duplicate issues exist, this returns 409 POSSIBLE_DUPLICATE unless force is true after reading them. " + `Do not use it when an existing issue already covers the work; read or update that issue instead. ${ISSUE_REFERENCE}`,
13622
13672
  arguments: (z) => ({
13623
13673
  project: z.string().describe("Project key for the new issue."),
13624
13674
  title: z.string().describe("Concise issue title."),
13625
13675
  parent: z.string().describe("Optional parent issue.").optional(),
13626
13676
  external: z.string().describe("Optional external issue reference.").optional(),
13677
+ force: z.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
13627
13678
  spec: z.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional()
13628
13679
  })
13629
13680
  },
@@ -13741,6 +13792,15 @@ var dispatchToolSpecs = [
13741
13792
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
13742
13793
  ref: z.string().describe("Optional dispatch:// issue reference.").optional()
13743
13794
  })
13795
+ },
13796
+ {
13797
+ name: "dispatch_search",
13798
+ description: "Search every issue, document, comment, ask, and message for a keyword or phrase and get deep links. " + "Use it before creating an issue or a design document, and to find where a word was written. " + 'Websearch syntax: "quoted phrase", -excluded, OR.',
13799
+ arguments: (z) => ({
13800
+ query: z.string({ min: 2 }).describe("Keyword, phrase, or websearch expression; at least 2 characters."),
13801
+ project: z.string().describe("Optional project key to search within.").optional(),
13802
+ limit: z.number({ int: true, min: 1, max: 50 }).describe("Maximum results, 1-50; default 20.").optional()
13803
+ })
13744
13804
  }
13745
13805
  ];
13746
13806
  // ../contracts/src/envelope.ts
@@ -13903,9 +13963,59 @@ var controllerIssue = strictObject({
13903
13963
  secret: nonEmptyString,
13904
13964
  issue: nonEmptyString
13905
13965
  });
13966
+ var TREE_STATUSES = ["queued", "active", "lingering", "dead", "launch-failed", "closed"];
13967
+ var stateWindowLocator = strictObject({
13968
+ tmuxSession: nonEmptyString,
13969
+ tmuxWindowId: nonEmptyString,
13970
+ tmuxPaneId: nonEmptyString.optional()
13971
+ });
13972
+ var stateTreeLocator = stateWindowLocator.extend({ ompSessionFile: nonEmptyString.optional() });
13973
+ var stateIssue = strictObject({
13974
+ key: nonEmptyString,
13975
+ title: string2(),
13976
+ status: _enum2(LIFECYCLE_STATUSES).optional(),
13977
+ children: array(nonEmptyString),
13978
+ parent: nonEmptyString.optional(),
13979
+ lastAppliedSeq: number2().int().nonnegative().optional()
13980
+ });
13981
+ var stateTree = strictObject({
13982
+ status: _enum2(TREE_STATUSES),
13983
+ generation: number2().int().nonnegative(),
13984
+ launchFailures: number2().int().nonnegative(),
13985
+ readyConfirmedAt: number2().optional(),
13986
+ locator: stateTreeLocator.optional()
13987
+ });
13988
+ var stateGate = strictObject({
13989
+ designAskId: nonEmptyString.optional(),
13990
+ designApproved: nonEmptyString.optional()
13991
+ });
13992
+ var stateRole = strictObject({
13993
+ role: nonEmptyString,
13994
+ issue: nonEmptyString.optional(),
13995
+ generation: number2().int().nonnegative().optional(),
13996
+ sessionId: nonEmptyString.optional(),
13997
+ readyConfirmedAt: number2().optional(),
13998
+ launchFailures: number2().int().nonnegative().optional(),
13999
+ locator: stateWindowLocator.optional()
14000
+ });
13906
14001
  var LegionDaemonApi = {
13907
14002
  State: {
13908
- response: object({ project: nonEmptyString })
14003
+ response: strictObject({
14004
+ project: nonEmptyString,
14005
+ version: number2().int(),
14006
+ issues: record(string2(), stateIssue),
14007
+ trees: record(string2(), stateTree),
14008
+ admission: strictObject({
14009
+ cap: number2().int().nonnegative(),
14010
+ active: array(nonEmptyString),
14011
+ queue: array(nonEmptyString)
14012
+ }),
14013
+ gates: record(string2(), stateGate),
14014
+ controllerLocator: stateWindowLocator.optional(),
14015
+ roles: record(string2(), stateRole),
14016
+ controllerPendingNotices: number2().int().nonnegative(),
14017
+ pendingStatusWrites: array(nonEmptyString)
14018
+ })
13909
14019
  },
13910
14020
  ControllerReady: {
13911
14021
  request: strictObject({ secret: nonEmptyString, sessionId: nonEmptyString }),
@@ -14331,6 +14441,9 @@ class DispatchClient {
14331
14441
  async listIssues(options = {}) {
14332
14442
  return this.#json("GET", ["api", "v1", "issues"], undefined, options);
14333
14443
  }
14444
+ async search(query, options = {}) {
14445
+ return this.#json("GET", ["api", "v1", "search"], undefined, { q: query, ...options });
14446
+ }
14334
14447
  async getIssue(issue) {
14335
14448
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
14336
14449
  }
@@ -14505,6 +14618,7 @@ class DispatchClient {
14505
14618
  var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
14506
14619
  var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
14507
14620
  var bareIssueNumberPattern = /^[1-9][0-9]*$/;
14621
+ var issueFreeTools = new Set(["dispatch_issue", "dispatch_resolve_ask", "dispatch_search"]);
14508
14622
  function canonicalExternalIssueRef(value) {
14509
14623
  const match = value.trim().match(externalIssueRefPattern);
14510
14624
  return match ? `${canonicalRepo(match[1] ?? "", match[2] ?? "")}#${match[3]}` : value;
@@ -14527,6 +14641,23 @@ function optionalNumber(args, name) {
14527
14641
  const value = args[name];
14528
14642
  return typeof value === "number" ? value : undefined;
14529
14643
  }
14644
+ function isDuplicateCandidate(value) {
14645
+ if (typeof value !== "object" || value === null)
14646
+ return false;
14647
+ const candidate = value;
14648
+ return typeof candidate.key === "string" && typeof candidate.title === "string" && typeof candidate.status === "string" && typeof candidate.snippet === "string" && typeof candidate.shared_terms === "number" && typeof candidate.href === "string";
14649
+ }
14650
+ function duplicateCandidates(error) {
14651
+ if (error.candidates === undefined || !error.candidates.every(isDuplicateCandidate))
14652
+ throw error;
14653
+ return error.candidates;
14654
+ }
14655
+ function searchResultLine(result, baseUrl) {
14656
+ const artifactName = result.artifact ? ` ${result.artifact.name}` : "";
14657
+ const label = `${result.issue.key} [${result.issue.status}] ${result.issue.title} - ${result.kind}${artifactName}`;
14658
+ const href = new URL(result.href, baseUrl).toString();
14659
+ return `${label}: ${snippetText(result.snippet)} -> ${href}`;
14660
+ }
14530
14661
  function askUrgency(args) {
14531
14662
  const value = args.urgency;
14532
14663
  return ASK_URGENCIES.find((urgency) => urgency === value);
@@ -14565,7 +14696,7 @@ function toolSchema(tool) {
14565
14696
  return dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
14566
14697
  }
14567
14698
  async function resolveIssueArguments(tool, args, cwd, env, exec) {
14568
- if (tool === "dispatch_issue" || tool === "dispatch_resolve_ask")
14699
+ if (issueFreeTools.has(tool))
14569
14700
  return { args, ref: null };
14570
14701
  const refArgument = args.ref;
14571
14702
  const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
@@ -14713,7 +14844,9 @@ async function openArtifactMarks(client, resolved) {
14713
14844
  ];
14714
14845
  }
14715
14846
  async function executeDispatchTool(input) {
14716
- if (!input.config.enabled || !input.config.url || !input.config.token) {
14847
+ const configUrl = input.config.url;
14848
+ const configToken = input.config.token;
14849
+ if (!input.config.enabled || !configUrl || !configToken) {
14717
14850
  throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
14718
14851
  }
14719
14852
  const env = input.env ?? process.env;
@@ -14721,8 +14854,8 @@ async function executeDispatchTool(input) {
14721
14854
  const issueArguments = await resolveIssueArguments(input.tool, input.args, input.cwd, env, exec);
14722
14855
  const args = toolSchema(input.tool).parse(issueArguments.args);
14723
14856
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
14724
- const client = new DispatchClient(input.config.url, input.config.token, input.fetchImpl);
14725
- const issueKey = input.tool === "dispatch_issue" || input.tool === "dispatch_resolve_ask" ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
14857
+ const client = new DispatchClient(configUrl, configToken, input.fetchImpl);
14858
+ const issueKey = issueFreeTools.has(input.tool) ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
14726
14859
  const issue = () => {
14727
14860
  if (issueKey === null)
14728
14861
  throw new Error("issue is required");
@@ -14730,20 +14863,62 @@ async function executeDispatchTool(input) {
14730
14863
  };
14731
14864
  switch (input.tool) {
14732
14865
  case "dispatch_issue": {
14866
+ const project = stringArg(args, "project");
14867
+ const title = stringArg(args, "title");
14733
14868
  const parent = optionalString(args, "parent");
14734
14869
  const external = optionalString(args, "external");
14870
+ const force = optionalBoolean(args, "force");
14735
14871
  const spec = optionalString(args, "spec");
14736
- const created = await client.issue({
14737
- project: stringArg(args, "project"),
14738
- title: stringArg(args, "title"),
14739
- ...parent === undefined ? {} : { parent },
14740
- ...external === undefined ? {} : { external },
14741
- ...spec === undefined ? {} : { spec },
14742
- actor
14872
+ try {
14873
+ const created = await client.issue({
14874
+ project,
14875
+ title,
14876
+ ...parent === undefined ? {} : { parent },
14877
+ ...external === undefined ? {} : { external },
14878
+ ...force === undefined ? {} : { force },
14879
+ ...spec === undefined ? {} : { spec },
14880
+ actor
14881
+ });
14882
+ return {
14883
+ text: `Created ${created.key}: ${created.title}`,
14884
+ details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
14885
+ };
14886
+ } catch (error) {
14887
+ if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
14888
+ throw error;
14889
+ }
14890
+ const candidates = duplicateCandidates(error);
14891
+ return {
14892
+ text: [
14893
+ `Not created: "${title}" looks like a duplicate.`,
14894
+ ...candidates.map((candidate) => {
14895
+ const href = new URL(candidate.href, configUrl).toString();
14896
+ return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
14897
+ }),
14898
+ "Reference the existing issue, or call dispatch_issue again with force: true after reading it."
14899
+ ].join(`
14900
+ `),
14901
+ details: { duplicates: candidates }
14902
+ };
14903
+ }
14904
+ }
14905
+ case "dispatch_search": {
14906
+ const query = stringArg(args, "query");
14907
+ const project = optionalString(args, "project");
14908
+ const limit = optionalNumber(args, "limit");
14909
+ const search = await client.search(query, {
14910
+ ...project === undefined ? {} : { project },
14911
+ ...limit === undefined ? {} : { limit }
14743
14912
  });
14913
+ const results = search.results;
14914
+ const count = results.length;
14744
14915
  return {
14745
- text: `Created ${created.key}: ${created.title}`,
14746
- details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
14916
+ text: count === 0 ? `No results for "${query}".` : [
14917
+ `${count} ${count === 1 ? "result" : "results"} for "${query}" (${search.took_ms} ms)`,
14918
+ ...results.map((result) => searchResultLine(result, configUrl))
14919
+ ].join(`
14920
+ `),
14921
+ details: { query, results }
14747
14922
  };
14748
14923
  }
14749
14924
  case "dispatch_resolve_ask": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -54,11 +54,24 @@ project configured for that repository in Dispatch Settings, then falls back to
54
54
 
55
55
  Architects create newly tracked child work with:
56
56
  ```ts
57
- dispatch_issue({ project, title, parent?, external?, spec? })
57
+ dispatch_issue({ project, title, parent?, external?, spec?, force? })
58
58
  ```
59
59
  It returns `details` `{ issue, topic }`. Use `dispatch_issue` only to create an issue; never use
60
- it to park a question.
61
- When `spec` is supplied, follow [Writing a spec](#writing-a-spec).
60
+ it to park a question. When `spec` is supplied, follow [Writing a spec](#writing-a-spec).
61
+
62
+ ## Search first
63
+
64
+ Before you create an issue or start a design document, search:
65
+ ```ts
66
+ dispatch_search({ query, project?, limit? })
67
+ ```
68
+ It returns every issue, document, comment, ask, and message that contains the words, with the
69
+ issue key and a link. Cite the hit you build on (`dispatch://KEY` or the document reference), or
70
+ state "no prior issue" in the spec. Websearch syntax applies: `"merge queue"`, `-daemon`, `OR`.
71
+
72
+ `dispatch_issue` refuses a title that near-duplicates an issue in the same project and returns
73
+ the candidates (`POSSIBLE_DUPLICATE`). Read them; reference the existing issue, or repeat the
74
+ call with `force: true` when it is genuinely new work.
62
75
 
63
76
  ## Asking
64
77