@sjawhar/pi-legion-envoy 0.34.0 → 0.35.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/README.md CHANGED
@@ -79,10 +79,10 @@ extension files it does not contain.
79
79
 
80
80
  ## Native Dispatch tools
81
81
 
82
- The extension registers `dispatch_issue`, `dispatch_ask`, `dispatch_resolve_ask`,
83
- `dispatch_comment`, `dispatch_suggest`, `dispatch_message`, `dispatch_doc_edit`,
84
- `dispatch_doc_read`, `dispatch_artifact`, and `dispatch_read` when Dispatch
85
- configuration resolves both a base URL and bearer token.
82
+ The extension registers eleven native Dispatch tools: `dispatch_issue`, `dispatch_ask`,
83
+ `dispatch_resolve_ask`, `dispatch_comment`, `dispatch_suggest`, `dispatch_message`,
84
+ `dispatch_doc_edit`, `dispatch_doc_read`, `dispatch_artifact`, `dispatch_read`, and
85
+ `dispatch_search`, when Dispatch configuration resolves both a base URL and bearer token.
86
86
 
87
87
  Configure the shared `envoy.json` with:
88
88
 
@@ -101,14 +101,14 @@ The user file is `~/.config/opencode/envoy.json`; a
101
101
  `DISPATCH_TOKEN` override the file values for one process. Omitting
102
102
  `dispatch.serverUrl` while `dispatch.enabled` is true targets
103
103
  `http://localhost:8766`, the Go server's listen address. Invalid configuration,
104
- an invalid URL, or an empty token leaves the nine tools unavailable and reports
104
+ an invalid URL, or an empty token leaves the eleven tools unavailable and reports
105
105
  the source of the error.
106
106
 
107
- Native tools operate on a Dispatch issue: a native `KEY` or an external
108
- `owner/repo#n` reference. A Legion session may omit `issue` when
109
- `LEGION_ISSUE` identifies its root issue and its working directory resolves to a
110
- repository. `dispatch_doc_read` and `dispatch_read` also accept
111
- `dispatch://` references.
107
+ Every native tool except `dispatch_search` operates on a Dispatch issue: a native
108
+ `KEY` or an external `owner/repo#n` reference. A Legion session may omit `issue` when
109
+ `LEGION_ISSUE` identifies its root issue and its working directory resolves to a repository.
110
+ `dispatch_doc_read` and `dispatch_read` also accept `dispatch://` references;
111
+ `dispatch_search` needs only its query and returns no subscription topic.
112
112
 
113
113
  Every mutation result carries `details.topic` as
114
114
  `notifications.dispatch.issue.<KEY>.>`. The extension's `tool_result` hook
package/dist/envoy.js CHANGED
@@ -29694,6 +29694,56 @@ var ChildStatusEventPayloadSchema = object({
29694
29694
  from: string2().optional(),
29695
29695
  to: string2().optional()
29696
29696
  });
29697
+ // ../contracts/src/dispatch-snippet.ts
29698
+ var HTML_ENTITIES = [
29699
+ ["&lt;", "<"],
29700
+ ["&gt;", ">"],
29701
+ ["&#39;", "'"],
29702
+ ["&#34;", '"'],
29703
+ ["&amp;", "&"]
29704
+ ];
29705
+ function decodeEntities(text) {
29706
+ let decoded = text;
29707
+ for (const [entity, character] of HTML_ENTITIES) {
29708
+ decoded = decoded.replaceAll(entity, character);
29709
+ }
29710
+ return decoded;
29711
+ }
29712
+ function hasOnlyBalancedMarkers(snippet) {
29713
+ let marked = false;
29714
+ for (const marker of snippet.matchAll(/<mark>|<\/mark>/gu)) {
29715
+ if (marker[0] === "<mark>") {
29716
+ if (marked)
29717
+ return false;
29718
+ marked = true;
29719
+ } else {
29720
+ if (!marked)
29721
+ return false;
29722
+ marked = false;
29723
+ }
29724
+ }
29725
+ return !marked;
29726
+ }
29727
+ function snippetSegments(snippet) {
29728
+ if (!hasOnlyBalancedMarkers(snippet)) {
29729
+ return snippet === "" ? [] : [{ text: decodeEntities(snippet), mark: false }];
29730
+ }
29731
+ let mark = false;
29732
+ const segments = [];
29733
+ for (const part of snippet.split(/(<mark>|<\/mark>)/u)) {
29734
+ if (part === "<mark>") {
29735
+ mark = true;
29736
+ } else if (part === "</mark>") {
29737
+ mark = false;
29738
+ } else if (part !== "") {
29739
+ segments.push({ text: decodeEntities(part), mark });
29740
+ }
29741
+ }
29742
+ return segments;
29743
+ }
29744
+ function snippetText(snippet) {
29745
+ return snippetSegments(snippet).map(({ text, mark }) => mark ? `**${text}**` : text).join("");
29746
+ }
29697
29747
  // ../contracts/src/dispatch-tools.ts
29698
29748
  function dispatchToolSchema(spec, z, opts) {
29699
29749
  const shape = spec.arguments(z);
@@ -29715,12 +29765,13 @@ var DOC_EDIT_OPS = ["replace", "delete", "insert"];
29715
29765
  var dispatchToolSpecs = [
29716
29766
  {
29717
29767
  name: "dispatch_issue",
29718
- 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}`,
29768
+ 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}`,
29719
29769
  arguments: (z) => ({
29720
29770
  project: z.string().describe("Project key for the new issue."),
29721
29771
  title: z.string().describe("Concise issue title."),
29722
29772
  parent: z.string().describe("Optional parent issue.").optional(),
29723
29773
  external: z.string().describe("Optional external issue reference.").optional(),
29774
+ force: z.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
29724
29775
  spec: z.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional()
29725
29776
  })
29726
29777
  },
@@ -29838,6 +29889,15 @@ var dispatchToolSpecs = [
29838
29889
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
29839
29890
  ref: z.string().describe("Optional dispatch:// issue reference.").optional()
29840
29891
  })
29892
+ },
29893
+ {
29894
+ name: "dispatch_search",
29895
+ 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.',
29896
+ arguments: (z) => ({
29897
+ query: z.string({ min: 2 }).describe("Keyword, phrase, or websearch expression; at least 2 characters."),
29898
+ project: z.string().describe("Optional project key to search within.").optional(),
29899
+ limit: z.number({ int: true, min: 1, max: 50 }).describe("Maximum results, 1-50; default 20.").optional()
29900
+ })
29841
29901
  }
29842
29902
  ];
29843
29903
  // ../contracts/src/envelope.ts
@@ -31169,6 +31229,9 @@ class DispatchClient {
31169
31229
  async listIssues(options = {}) {
31170
31230
  return this.#json("GET", ["api", "v1", "issues"], undefined, options);
31171
31231
  }
31232
+ async search(query, options = {}) {
31233
+ return this.#json("GET", ["api", "v1", "search"], undefined, { q: query, ...options });
31234
+ }
31172
31235
  async getIssue(issue) {
31173
31236
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
31174
31237
  }
@@ -31343,6 +31406,7 @@ class DispatchClient {
31343
31406
  var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
31344
31407
  var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
31345
31408
  var bareIssueNumberPattern = /^[1-9][0-9]*$/;
31409
+ var issueFreeTools = new Set(["dispatch_issue", "dispatch_resolve_ask", "dispatch_search"]);
31346
31410
  function canonicalExternalIssueRef(value) {
31347
31411
  const match = value.trim().match(externalIssueRefPattern);
31348
31412
  return match ? `${canonicalRepo(match[1] ?? "", match[2] ?? "")}#${match[3]}` : value;
@@ -31365,6 +31429,23 @@ function optionalNumber(args, name) {
31365
31429
  const value = args[name];
31366
31430
  return typeof value === "number" ? value : undefined;
31367
31431
  }
31432
+ function isDuplicateCandidate(value) {
31433
+ if (typeof value !== "object" || value === null)
31434
+ return false;
31435
+ const candidate = value;
31436
+ 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";
31437
+ }
31438
+ function duplicateCandidates(error) {
31439
+ if (error.candidates === undefined || !error.candidates.every(isDuplicateCandidate))
31440
+ throw error;
31441
+ return error.candidates;
31442
+ }
31443
+ function searchResultLine(result, baseUrl) {
31444
+ const artifactName = result.artifact ? ` ${result.artifact.name}` : "";
31445
+ const label = `${result.issue.key} [${result.issue.status}] ${result.issue.title} - ${result.kind}${artifactName}`;
31446
+ const href = new URL(result.href, baseUrl).toString();
31447
+ return `${label}: ${snippetText(result.snippet)} -> ${href}`;
31448
+ }
31368
31449
  function askUrgency(args) {
31369
31450
  const value = args.urgency;
31370
31451
  return ASK_URGENCIES.find((urgency) => urgency === value);
@@ -31403,7 +31484,7 @@ function toolSchema(tool) {
31403
31484
  return dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
31404
31485
  }
31405
31486
  async function resolveIssueArguments(tool, args, cwd, env, exec) {
31406
- if (tool === "dispatch_issue" || tool === "dispatch_resolve_ask")
31487
+ if (issueFreeTools.has(tool))
31407
31488
  return { args, ref: null };
31408
31489
  const refArgument = args.ref;
31409
31490
  const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
@@ -31551,7 +31632,9 @@ async function openArtifactMarks(client, resolved) {
31551
31632
  ];
31552
31633
  }
31553
31634
  async function executeDispatchTool(input) {
31554
- if (!input.config.enabled || !input.config.url || !input.config.token) {
31635
+ const configUrl = input.config.url;
31636
+ const configToken = input.config.token;
31637
+ if (!input.config.enabled || !configUrl || !configToken) {
31555
31638
  throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
31556
31639
  }
31557
31640
  const env = input.env ?? process.env;
@@ -31559,8 +31642,8 @@ async function executeDispatchTool(input) {
31559
31642
  const issueArguments = await resolveIssueArguments(input.tool, input.args, input.cwd, env, exec);
31560
31643
  const args = toolSchema(input.tool).parse(issueArguments.args);
31561
31644
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
31562
- const client = new DispatchClient(input.config.url, input.config.token, input.fetchImpl);
31563
- const issueKey = input.tool === "dispatch_issue" || input.tool === "dispatch_resolve_ask" ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
31645
+ const client = new DispatchClient(configUrl, configToken, input.fetchImpl);
31646
+ const issueKey = issueFreeTools.has(input.tool) ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
31564
31647
  const issue = () => {
31565
31648
  if (issueKey === null)
31566
31649
  throw new Error("issue is required");
@@ -31568,20 +31651,62 @@ async function executeDispatchTool(input) {
31568
31651
  };
31569
31652
  switch (input.tool) {
31570
31653
  case "dispatch_issue": {
31654
+ const project = stringArg(args, "project");
31655
+ const title = stringArg(args, "title");
31571
31656
  const parent = optionalString(args, "parent");
31572
31657
  const external = optionalString(args, "external");
31658
+ const force = optionalBoolean(args, "force");
31573
31659
  const spec = optionalString(args, "spec");
31574
- const created = await client.issue({
31575
- project: stringArg(args, "project"),
31576
- title: stringArg(args, "title"),
31577
- ...parent === undefined ? {} : { parent },
31578
- ...external === undefined ? {} : { external },
31579
- ...spec === undefined ? {} : { spec },
31580
- actor
31660
+ try {
31661
+ const created = await client.issue({
31662
+ project,
31663
+ title,
31664
+ ...parent === undefined ? {} : { parent },
31665
+ ...external === undefined ? {} : { external },
31666
+ ...force === undefined ? {} : { force },
31667
+ ...spec === undefined ? {} : { spec },
31668
+ actor
31669
+ });
31670
+ return {
31671
+ text: `Created ${created.key}: ${created.title}`,
31672
+ details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
31673
+ };
31674
+ } catch (error) {
31675
+ if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
31676
+ throw error;
31677
+ }
31678
+ const candidates = duplicateCandidates(error);
31679
+ return {
31680
+ text: [
31681
+ `Not created: "${title}" looks like a duplicate.`,
31682
+ ...candidates.map((candidate) => {
31683
+ const href = new URL(candidate.href, configUrl).toString();
31684
+ return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
31685
+ }),
31686
+ "Reference the existing issue, or call dispatch_issue again with force: true after reading it."
31687
+ ].join(`
31688
+ `),
31689
+ details: { duplicates: candidates }
31690
+ };
31691
+ }
31692
+ }
31693
+ case "dispatch_search": {
31694
+ const query = stringArg(args, "query");
31695
+ const project = optionalString(args, "project");
31696
+ const limit = optionalNumber(args, "limit");
31697
+ const search = await client.search(query, {
31698
+ ...project === undefined ? {} : { project },
31699
+ ...limit === undefined ? {} : { limit }
31581
31700
  });
31701
+ const results = search.results;
31702
+ const count = results.length;
31582
31703
  return {
31583
- text: `Created ${created.key}: ${created.title}`,
31584
- details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
31704
+ text: count === 0 ? `No results for "${query}".` : [
31705
+ `${count} ${count === 1 ? "result" : "results"} for "${query}" (${search.took_ms} ms)`,
31706
+ ...results.map((result) => searchResultLine(result, configUrl))
31707
+ ].join(`
31708
+ `),
31709
+ details: { query, results }
31585
31710
  };
31586
31711
  }
31587
31712
  case "dispatch_resolve_ask": {
package/dist/legion.js CHANGED
@@ -29692,6 +29692,56 @@ var ChildStatusEventPayloadSchema = object({
29692
29692
  from: string2().optional(),
29693
29693
  to: string2().optional()
29694
29694
  });
29695
+ // ../contracts/src/dispatch-snippet.ts
29696
+ var HTML_ENTITIES = [
29697
+ ["&lt;", "<"],
29698
+ ["&gt;", ">"],
29699
+ ["&#39;", "'"],
29700
+ ["&#34;", '"'],
29701
+ ["&amp;", "&"]
29702
+ ];
29703
+ function decodeEntities(text) {
29704
+ let decoded = text;
29705
+ for (const [entity, character] of HTML_ENTITIES) {
29706
+ decoded = decoded.replaceAll(entity, character);
29707
+ }
29708
+ return decoded;
29709
+ }
29710
+ function hasOnlyBalancedMarkers(snippet) {
29711
+ let marked = false;
29712
+ for (const marker of snippet.matchAll(/<mark>|<\/mark>/gu)) {
29713
+ if (marker[0] === "<mark>") {
29714
+ if (marked)
29715
+ return false;
29716
+ marked = true;
29717
+ } else {
29718
+ if (!marked)
29719
+ return false;
29720
+ marked = false;
29721
+ }
29722
+ }
29723
+ return !marked;
29724
+ }
29725
+ function snippetSegments(snippet) {
29726
+ if (!hasOnlyBalancedMarkers(snippet)) {
29727
+ return snippet === "" ? [] : [{ text: decodeEntities(snippet), mark: false }];
29728
+ }
29729
+ let mark = false;
29730
+ const segments = [];
29731
+ for (const part of snippet.split(/(<mark>|<\/mark>)/u)) {
29732
+ if (part === "<mark>") {
29733
+ mark = true;
29734
+ } else if (part === "</mark>") {
29735
+ mark = false;
29736
+ } else if (part !== "") {
29737
+ segments.push({ text: decodeEntities(part), mark });
29738
+ }
29739
+ }
29740
+ return segments;
29741
+ }
29742
+ function snippetText(snippet) {
29743
+ return snippetSegments(snippet).map(({ text, mark }) => mark ? `**${text}**` : text).join("");
29744
+ }
29695
29745
  // ../contracts/src/dispatch-tools.ts
29696
29746
  function dispatchToolSchema(spec, z, opts) {
29697
29747
  const shape = spec.arguments(z);
@@ -29713,12 +29763,13 @@ var DOC_EDIT_OPS = ["replace", "delete", "insert"];
29713
29763
  var dispatchToolSpecs = [
29714
29764
  {
29715
29765
  name: "dispatch_issue",
29716
- 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}`,
29766
+ 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}`,
29717
29767
  arguments: (z) => ({
29718
29768
  project: z.string().describe("Project key for the new issue."),
29719
29769
  title: z.string().describe("Concise issue title."),
29720
29770
  parent: z.string().describe("Optional parent issue.").optional(),
29721
29771
  external: z.string().describe("Optional external issue reference.").optional(),
29772
+ force: z.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
29722
29773
  spec: z.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional()
29723
29774
  })
29724
29775
  },
@@ -29836,6 +29887,15 @@ var dispatchToolSpecs = [
29836
29887
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
29837
29888
  ref: z.string().describe("Optional dispatch:// issue reference.").optional()
29838
29889
  })
29890
+ },
29891
+ {
29892
+ name: "dispatch_search",
29893
+ 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.',
29894
+ arguments: (z) => ({
29895
+ query: z.string({ min: 2 }).describe("Keyword, phrase, or websearch expression; at least 2 characters."),
29896
+ project: z.string().describe("Optional project key to search within.").optional(),
29897
+ limit: z.number({ int: true, min: 1, max: 50 }).describe("Maximum results, 1-50; default 20.").optional()
29898
+ })
29839
29899
  }
29840
29900
  ];
29841
29901
  // ../contracts/src/envelope.ts
@@ -31608,6 +31668,9 @@ class DispatchClient {
31608
31668
  async listIssues(options = {}) {
31609
31669
  return this.#json("GET", ["api", "v1", "issues"], undefined, options);
31610
31670
  }
31671
+ async search(query, options = {}) {
31672
+ return this.#json("GET", ["api", "v1", "search"], undefined, { q: query, ...options });
31673
+ }
31611
31674
  async getIssue(issue) {
31612
31675
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
31613
31676
  }
@@ -31782,6 +31845,7 @@ class DispatchClient {
31782
31845
  var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
31783
31846
  var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
31784
31847
  var bareIssueNumberPattern = /^[1-9][0-9]*$/;
31848
+ var issueFreeTools = new Set(["dispatch_issue", "dispatch_resolve_ask", "dispatch_search"]);
31785
31849
  function canonicalExternalIssueRef(value) {
31786
31850
  const match = value.trim().match(externalIssueRefPattern);
31787
31851
  return match ? `${canonicalRepo(match[1] ?? "", match[2] ?? "")}#${match[3]}` : value;
@@ -31804,6 +31868,23 @@ function optionalNumber(args, name) {
31804
31868
  const value = args[name];
31805
31869
  return typeof value === "number" ? value : undefined;
31806
31870
  }
31871
+ function isDuplicateCandidate(value) {
31872
+ if (typeof value !== "object" || value === null)
31873
+ return false;
31874
+ const candidate = value;
31875
+ 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";
31876
+ }
31877
+ function duplicateCandidates(error) {
31878
+ if (error.candidates === undefined || !error.candidates.every(isDuplicateCandidate))
31879
+ throw error;
31880
+ return error.candidates;
31881
+ }
31882
+ function searchResultLine(result, baseUrl) {
31883
+ const artifactName = result.artifact ? ` ${result.artifact.name}` : "";
31884
+ const label = `${result.issue.key} [${result.issue.status}] ${result.issue.title} - ${result.kind}${artifactName}`;
31885
+ const href = new URL(result.href, baseUrl).toString();
31886
+ return `${label}: ${snippetText(result.snippet)} -> ${href}`;
31887
+ }
31807
31888
  function askUrgency(args) {
31808
31889
  const value = args.urgency;
31809
31890
  return ASK_URGENCIES.find((urgency) => urgency === value);
@@ -31842,7 +31923,7 @@ function toolSchema(tool) {
31842
31923
  return dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
31843
31924
  }
31844
31925
  async function resolveIssueArguments(tool, args, cwd, env, exec) {
31845
- if (tool === "dispatch_issue" || tool === "dispatch_resolve_ask")
31926
+ if (issueFreeTools.has(tool))
31846
31927
  return { args, ref: null };
31847
31928
  const refArgument = args.ref;
31848
31929
  const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
@@ -31990,7 +32071,9 @@ async function openArtifactMarks(client, resolved) {
31990
32071
  ];
31991
32072
  }
31992
32073
  async function executeDispatchTool(input) {
31993
- if (!input.config.enabled || !input.config.url || !input.config.token) {
32074
+ const configUrl = input.config.url;
32075
+ const configToken = input.config.token;
32076
+ if (!input.config.enabled || !configUrl || !configToken) {
31994
32077
  throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
31995
32078
  }
31996
32079
  const env = input.env ?? process.env;
@@ -31998,8 +32081,8 @@ async function executeDispatchTool(input) {
31998
32081
  const issueArguments = await resolveIssueArguments(input.tool, input.args, input.cwd, env, exec);
31999
32082
  const args = toolSchema(input.tool).parse(issueArguments.args);
32000
32083
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
32001
- const client = new DispatchClient(input.config.url, input.config.token, input.fetchImpl);
32002
- const issueKey = input.tool === "dispatch_issue" || input.tool === "dispatch_resolve_ask" ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
32084
+ const client = new DispatchClient(configUrl, configToken, input.fetchImpl);
32085
+ const issueKey = issueFreeTools.has(input.tool) ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
32003
32086
  const issue = () => {
32004
32087
  if (issueKey === null)
32005
32088
  throw new Error("issue is required");
@@ -32007,20 +32090,62 @@ async function executeDispatchTool(input) {
32007
32090
  };
32008
32091
  switch (input.tool) {
32009
32092
  case "dispatch_issue": {
32093
+ const project = stringArg(args, "project");
32094
+ const title = stringArg(args, "title");
32010
32095
  const parent = optionalString(args, "parent");
32011
32096
  const external = optionalString(args, "external");
32097
+ const force = optionalBoolean(args, "force");
32012
32098
  const spec = optionalString(args, "spec");
32013
- const created = await client.issue({
32014
- project: stringArg(args, "project"),
32015
- title: stringArg(args, "title"),
32016
- ...parent === undefined ? {} : { parent },
32017
- ...external === undefined ? {} : { external },
32018
- ...spec === undefined ? {} : { spec },
32019
- actor
32099
+ try {
32100
+ const created = await client.issue({
32101
+ project,
32102
+ title,
32103
+ ...parent === undefined ? {} : { parent },
32104
+ ...external === undefined ? {} : { external },
32105
+ ...force === undefined ? {} : { force },
32106
+ ...spec === undefined ? {} : { spec },
32107
+ actor
32108
+ });
32109
+ return {
32110
+ text: `Created ${created.key}: ${created.title}`,
32111
+ details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
32112
+ };
32113
+ } catch (error) {
32114
+ if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
32115
+ throw error;
32116
+ }
32117
+ const candidates = duplicateCandidates(error);
32118
+ return {
32119
+ text: [
32120
+ `Not created: "${title}" looks like a duplicate.`,
32121
+ ...candidates.map((candidate) => {
32122
+ const href = new URL(candidate.href, configUrl).toString();
32123
+ return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
32124
+ }),
32125
+ "Reference the existing issue, or call dispatch_issue again with force: true after reading it."
32126
+ ].join(`
32127
+ `),
32128
+ details: { duplicates: candidates }
32129
+ };
32130
+ }
32131
+ }
32132
+ case "dispatch_search": {
32133
+ const query = stringArg(args, "query");
32134
+ const project = optionalString(args, "project");
32135
+ const limit = optionalNumber(args, "limit");
32136
+ const search = await client.search(query, {
32137
+ ...project === undefined ? {} : { project },
32138
+ ...limit === undefined ? {} : { limit }
32020
32139
  });
32140
+ const results = search.results;
32141
+ const count = results.length;
32021
32142
  return {
32022
- text: `Created ${created.key}: ${created.title}`,
32023
- details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
32143
+ text: count === 0 ? `No results for "${query}".` : [
32144
+ `${count} ${count === 1 ? "result" : "results"} for "${query}" (${search.took_ms} ms)`,
32145
+ ...results.map((result) => searchResultLine(result, configUrl))
32146
+ ].join(`
32147
+ `),
32148
+ details: { query, results }
32024
32149
  };
32025
32150
  }
32026
32151
  case "dispatch_resolve_ask": {
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [