@sjawhar/opencode-legion-envoy 8.30.2 → 8.31.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/dispatch.js CHANGED
@@ -14026,6 +14026,7 @@ var SEARCH_QUERY_HINT = "search with a short phrase of a few words, not a passag
14026
14026
  var SEARCH_KIND_DEPTH = 100;
14027
14027
  var PROJECT_KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
14028
14028
  var PROJECT_KEY_RULE = "an uppercase letter, then 1 to 9 uppercase letters or digits, such as CORE";
14029
+ var ARCHITECTURE_SOURCE_REPO_PATTERN = /^[A-Za-z0-9-]+\/(?!\.\.?$)[A-Za-z0-9._-]+$/;
14029
14030
  var ISSUE_STATUSES = [
14030
14031
  "triage",
14031
14032
  "icebox",
@@ -14391,7 +14392,7 @@ var dispatchToolSpecs = [
14391
14392
  {
14392
14393
  name: "dispatch_issues",
14393
14394
  example: { project: "PROJ", limit: 250, offset: 250 },
14394
- description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never.",
14395
+ description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never. " + "all takes the whole filtered list in one read instead, for a caller that must not miss or " + "repeat a row while other writers change the list, and especially for one whose own writes " + "move issues out of what it filtered on.",
14395
14396
  arguments: (z2) => ({
14396
14397
  project: z2.string().describe("Project key to list issues from."),
14397
14398
  status: z2.enum(ISSUE_STATUSES).describe("Optional lifecycle status filter.").optional(),
@@ -14401,7 +14402,8 @@ var dispatchToolSpecs = [
14401
14402
  updated_since: z2.string().describe("Optional RFC3339 timestamp; only issues updated at or after it.").optional(),
14402
14403
  route_status: z2.enum(ISSUE_ROUTE_STATUSES).describe("Optional: only open issues whose route is in this state. live: a running session holds " + "the role or is the routed session. no_holder: nobody running holds the role, or the " + "session is not running, right now. unknown: the Envoy listener did not answer.").optional(),
14403
14404
  limit: z2.number({ int: true, min: 1, max: MAX_ISSUE_PAGE_LIMIT }).describe(`Maximum rows, 1-${MAX_ISSUE_PAGE_LIMIT}; default ${DEFAULT_ISSUE_PAGE_LIMIT}.`).optional(),
14404
- offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional()
14405
+ offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional(),
14406
+ all: z2.boolean().describe("Take every matching issue in one unpaged read, so no row is missed or repeated while " + "the list changes. Refused with limit or offset.").optional()
14405
14407
  })
14406
14408
  },
14407
14409
  {
@@ -14423,11 +14425,19 @@ var dispatchToolSpecs = [
14423
14425
  },
14424
14426
  {
14425
14427
  name: "dispatch_architecture_sync",
14426
- example: { project: "CORE" },
14427
- description: "Import a project's architecture model from its configured source repository now, instead of " + "waiting for the server's five-minute schedule. Returns the imported commit and component " + "count, or the recorded error when the model was rejected (the previous model stays up). " + "The source itself is configured by a human in Settings; 404 SOURCE_NOT_FOUND without one.",
14428
+ example: { project: "CORE", repo: "acme/infra" },
14429
+ description: "Import a project's architecture model from its source repositories now, instead of waiting " + "for the server's five-minute schedule. A project may have several sources, one per " + "repository, and its model is every source's components together. Without repo every " + "source imports and each answers on its own line; with repo only that source does. Each " + "line names the imported commit, or the recorded error when that source's model was " + "rejected (its previous components stay up, and every other source is untouched). Sources " + "are saved in Settings or with PUT /api/v1/projects/{key}/architecture-sources/{owner}/{repo}; " + "404 SOURCE_NOT_FOUND when the project has none, or none for that repository.",
14428
14430
  arguments: (z2) => ({
14429
- project: z2.string().describe("Project key whose architecture source to sync, such as CORE.")
14431
+ project: z2.string().describe("Project key whose architecture sources to sync, such as CORE."),
14432
+ repo: z2.string().describe("One source repository of the project to sync alone, owner/name; omit it to sync every source.").optional()
14430
14433
  }),
14434
+ validation: {
14435
+ check: (value) => {
14436
+ const { repo } = value;
14437
+ return typeof repo !== "string" || ARCHITECTURE_SOURCE_REPO_PATTERN.test(repo);
14438
+ },
14439
+ message: "repo is not a repository: give it as owner/name, such as acme/infra."
14440
+ },
14431
14441
  strict: true
14432
14442
  },
14433
14443
  {
@@ -14805,9 +14815,8 @@ function parseCommand(argv, io) {
14805
14815
  const token = rest[index];
14806
14816
  if (!token.startsWith("--")) {
14807
14817
  if (ISSUE_KEY.test(token) && surface.flags.has("--issue") && !setBy.has("issue")) {
14808
- const corrected = [command, ...rest.slice(0, index), "--issue", token];
14809
- corrected.push(...rest.slice(index + 1));
14810
- problems.push(`unexpected argument ${token}: use --issue <KEY>, as in: dispatch ${corrected.map(quoteWord).join(" ")}`);
14818
+ if (claim("issue", "--issue", false))
14819
+ args.issue = token;
14811
14820
  continue;
14812
14821
  }
14813
14822
  problems.push(`unexpected argument ${quoteWord(token)}: every value follows its flag`);
@@ -15164,6 +15173,9 @@ function asErrorShape(value) {
15164
15173
  function isJson(response) {
15165
15174
  return response.headers.get("content-type")?.includes("application/json") ?? false;
15166
15175
  }
15176
+ function isNoRoute(error48) {
15177
+ return error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "NOT_FOUND" && error48.message.startsWith("no route for ");
15178
+ }
15167
15179
  var ISSUE_EVENTS_PAGE_MAX = 200;
15168
15180
  var DISPATCH_TOOL_DEADLINE_MS = 60000;
15169
15181
  function requestSignal(signal) {
@@ -15191,6 +15203,18 @@ function whyNotAPage(answer) {
15191
15203
  }
15192
15204
  return `${what}. Retrying will not help: the same request gets the same answer until that ` + "Dispatch is upgraded or fixed.";
15193
15205
  }
15206
+ function whyNotAnArray(answer) {
15207
+ if (typeof answer === "string")
15208
+ return `text that is not JSON, ${GATEWAY_PAGE}, so a retry may succeed`;
15209
+ if (answer === null)
15210
+ return "null";
15211
+ if (typeof answer !== "object")
15212
+ return `a ${typeof answer}`;
15213
+ if ("issues" in answer && Array.isArray(answer.issues)) {
15214
+ return "a page, so this Dispatch pages a request that carries no limit and no offset";
15215
+ }
15216
+ return "an object that is not the listing";
15217
+ }
15194
15218
  var GATEWAY_PAGE = "which looks like a proxy or gateway page rather than Dispatch's own answer";
15195
15219
  function gatewayAdvice(method, status) {
15196
15220
  if (!transientStatus(status)) {
@@ -15266,6 +15290,14 @@ class DispatchClient {
15266
15290
  return answer;
15267
15291
  throw new Error(`GET ${this.#url(path, query)} asked for a page ({issues, total, limit, offset}) and got ` + refusal);
15268
15292
  }
15293
+ async listAllIssues(options) {
15294
+ const path = ["api", "v1", "issues"];
15295
+ const query = { ...options };
15296
+ const answer = await this.#json("GET", path, undefined, query);
15297
+ if (Array.isArray(answer))
15298
+ return answer;
15299
+ throw new Error(`GET ${this.#url(path, query)} asked for the unpaged listing and got ${whyNotAnArray(answer)}.`);
15300
+ }
15269
15301
  async listProjectArtifacts(project, unlinked = false) {
15270
15302
  return this.#json("GET", ["api", "v1", "projects", project, "artifacts"], undefined, unlinked ? { unlinked: "true" } : undefined);
15271
15303
  }
@@ -15354,19 +15386,69 @@ class DispatchClient {
15354
15386
  async listProjects() {
15355
15387
  return this.#json("GET", ["api", "v1", "projects"]);
15356
15388
  }
15357
- async syncArchitectureSource(project) {
15358
- return this.#json("POST", ["api", "v1", "projects", project, "architecture-source", "sync"]);
15359
- }
15360
- async getArchitectureSource(project) {
15389
+ async listArchitectureSources(project) {
15361
15390
  try {
15362
- return await this.#json("GET", ["api", "v1", "projects", project, "architecture-source"]);
15391
+ return await this.#json("GET", ["api", "v1", "projects", project, "architecture-sources"]);
15392
+ } catch (error48) {
15393
+ if (!isNoRoute(error48))
15394
+ throw error48;
15395
+ }
15396
+ try {
15397
+ const source = await this.#json("GET", [
15398
+ "api",
15399
+ "v1",
15400
+ "projects",
15401
+ project,
15402
+ "architecture-source"
15403
+ ]);
15404
+ return source === null ? [] : [source];
15363
15405
  } catch (error48) {
15364
15406
  if (error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "SOURCE_NOT_FOUND") {
15365
- return null;
15407
+ return [];
15366
15408
  }
15367
15409
  throw error48;
15368
15410
  }
15369
15411
  }
15412
+ async syncArchitectureSources(project) {
15413
+ try {
15414
+ return await this.#json("POST", [
15415
+ "api",
15416
+ "v1",
15417
+ "projects",
15418
+ project,
15419
+ "architecture-sources",
15420
+ "sync"
15421
+ ]);
15422
+ } catch (error48) {
15423
+ if (!isNoRoute(error48))
15424
+ throw error48;
15425
+ }
15426
+ const source = await this.#json("POST", [
15427
+ "api",
15428
+ "v1",
15429
+ "projects",
15430
+ project,
15431
+ "architecture-source",
15432
+ "sync"
15433
+ ]);
15434
+ return [source];
15435
+ }
15436
+ async syncArchitectureSource(project, repo2) {
15437
+ const [owner, name, ...rest] = repo2.split("/");
15438
+ if (!owner || !name || rest.length > 0) {
15439
+ throw new Error(`repository must be owner/name, got ${JSON.stringify(repo2)}`);
15440
+ }
15441
+ return this.#json("POST", [
15442
+ "api",
15443
+ "v1",
15444
+ "projects",
15445
+ project,
15446
+ "architecture-sources",
15447
+ owner,
15448
+ name,
15449
+ "sync"
15450
+ ]);
15451
+ }
15370
15452
  async resolveAsk(id, input) {
15371
15453
  return this.#json("POST", ["api", "v1", "asks", id, "resolve"], input);
15372
15454
  }
@@ -16445,14 +16527,31 @@ async function architectureGuidance(client, project, components) {
16445
16527
  if (components.mode === "none" || components.ids.length > 0)
16446
16528
  return;
16447
16529
  try {
16448
- if (await client.getArchitectureSource(project) === null) {
16530
+ if ((await client.listArchitectureSources(project)).length === 0)
16449
16531
  return;
16450
- }
16451
16532
  return `Project ${project} has an architecture model, but this issue is not linked to any of its current components. ${architectureComponentsAction}`;
16452
16533
  } catch (error48) {
16453
16534
  return `Could not check whether project ${project} has an architecture model: ${messageFor(error48)}. ${architectureComponentsAction}`;
16454
16535
  }
16455
16536
  }
16537
+ function architectureSyncLines(project, source) {
16538
+ if (source.last_error === null) {
16539
+ return `Synced ${project} architecture from ${source.repo}@${source.branch}: commit ${source.last_commit ?? "unknown"} (${source.last_sync_at ?? "unknown time"}).`;
16540
+ }
16541
+ let kept;
16542
+ if (source.last_commit !== null) {
16543
+ kept = `The previous model of ${source.repo} stays up (commit ${source.last_commit}).`;
16544
+ } else if (source.components > 0) {
16545
+ kept = `The previous model of ${source.repo} stays up (${source.components} ${source.components === 1 ? "component" : "components"}).`;
16546
+ } else {
16547
+ kept = `No model has ever imported from ${source.repo}.`;
16548
+ }
16549
+ return [
16550
+ `Sync failed for ${project} (${source.repo}@${source.branch}): ${source.last_error}`,
16551
+ kept
16552
+ ].join(`
16553
+ `);
16554
+ }
16456
16555
  function isDuplicateCandidate(value) {
16457
16556
  if (typeof value !== "object" || value === null)
16458
16557
  return false;
@@ -16696,6 +16795,18 @@ function argumentProblems(tool, args) {
16696
16795
  }
16697
16796
  break;
16698
16797
  }
16798
+ case "dispatch_issues": {
16799
+ if (optionalBoolean(args, "all") === true) {
16800
+ const paging = [
16801
+ ...optionalNumber(args, "limit") === undefined ? [] : ["--limit"],
16802
+ ...optionalNumber(args, "offset") === undefined ? [] : ["--offset"]
16803
+ ];
16804
+ if (paging.length > 0) {
16805
+ problems.push(`--all takes every matching issue in one unpaged read, so it cannot be combined with ${paging.join(" or ")}: name --all on its own, or page with --limit and --offset`);
16806
+ }
16807
+ }
16808
+ break;
16809
+ }
16699
16810
  }
16700
16811
  return problems;
16701
16812
  }
@@ -18013,7 +18124,7 @@ async function executeDispatchTool(input) {
18013
18124
  const priority = optionalPriorityFilter(args, "priority");
18014
18125
  const updatedSince = optionalString(args, "updated_since");
18015
18126
  const routeStatus = optionalString(args, "route_status");
18016
- const page = await client.listIssuePage({
18127
+ const filters = {
18017
18128
  project,
18018
18129
  ...status === undefined ? {} : { status },
18019
18130
  ...parent === undefined ? {} : { parent },
@@ -18021,7 +18132,13 @@ async function executeDispatchTool(input) {
18021
18132
  ...priority === undefined ? {} : { priority },
18022
18133
  ...updatedSince === undefined ? {} : { updated_since: updatedSince },
18023
18134
  ...routeStatus === undefined ? {} : { route_status: routeStatus }
18024
- }, {
18135
+ };
18136
+ const page = optionalBoolean(args, "all") === true ? await client.listAllIssues(filters).then((issues) => ({
18137
+ issues,
18138
+ total: issues.length,
18139
+ limit: issues.length,
18140
+ offset: 0
18141
+ })) : await client.listIssuePage(filters, {
18025
18142
  limit: optionalNumber(args, "limit") ?? DEFAULT_ISSUE_PAGE_LIMIT,
18026
18143
  offset: optionalNumber(args, "offset") ?? 0
18027
18144
  });
@@ -18083,20 +18200,19 @@ async function executeDispatchTool(input) {
18083
18200
  }
18084
18201
  case "dispatch_architecture_sync": {
18085
18202
  const project = stringArg(args, "project");
18086
- const source = await client.syncArchitectureSource(project);
18087
- const at = source.last_sync_at ?? "unknown time";
18203
+ const repo2 = optionalString(args, "repo");
18204
+ const sources = repo2 === undefined ? await client.syncArchitectureSources(project) : [await client.syncArchitectureSource(project, repo2)];
18088
18205
  return {
18089
- text: source.last_error === null ? `Synced ${project} architecture from ${source.repo}@${source.branch}: commit ${source.last_commit ?? "unknown"} (${at}).` : [
18090
- `Sync failed for ${project} (${source.repo}@${source.branch}): ${source.last_error}`,
18091
- source.last_commit === null ? "No model has ever imported for this project." : `The previous model stays up (commit ${source.last_commit}).`
18092
- ].join(`
18206
+ text: sources.map((source) => architectureSyncLines(project, source)).join(`
18093
18207
  `),
18094
18208
  details: {
18095
18209
  project,
18096
- repo: source.repo,
18097
- branch: source.branch,
18098
- commit: source.last_commit,
18099
- error: source.last_error
18210
+ sources: sources.map((source) => ({
18211
+ repo: source.repo,
18212
+ branch: source.branch,
18213
+ commit: source.last_commit,
18214
+ error: source.last_error
18215
+ }))
18100
18216
  }
18101
18217
  };
18102
18218
  }
@@ -13883,6 +13883,7 @@ var SEARCH_QUERY_HINT = "search with a short phrase of a few words, not a passag
13883
13883
  var SEARCH_KIND_DEPTH = 100;
13884
13884
  var PROJECT_KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
13885
13885
  var PROJECT_KEY_RULE = "an uppercase letter, then 1 to 9 uppercase letters or digits, such as CORE";
13886
+ var ARCHITECTURE_SOURCE_REPO_PATTERN = /^[A-Za-z0-9-]+\/(?!\.\.?$)[A-Za-z0-9._-]+$/;
13886
13887
  var ISSUE_STATUSES = [
13887
13888
  "triage",
13888
13889
  "icebox",
@@ -14248,7 +14249,7 @@ var dispatchToolSpecs = [
14248
14249
  {
14249
14250
  name: "dispatch_issues",
14250
14251
  example: { project: "PROJ", limit: 250, offset: 250 },
14251
- description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never.",
14252
+ description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never. " + "all takes the whole filtered list in one read instead, for a caller that must not miss or " + "repeat a row while other writers change the list, and especially for one whose own writes " + "move issues out of what it filtered on.",
14252
14253
  arguments: (z2) => ({
14253
14254
  project: z2.string().describe("Project key to list issues from."),
14254
14255
  status: z2.enum(ISSUE_STATUSES).describe("Optional lifecycle status filter.").optional(),
@@ -14258,7 +14259,8 @@ var dispatchToolSpecs = [
14258
14259
  updated_since: z2.string().describe("Optional RFC3339 timestamp; only issues updated at or after it.").optional(),
14259
14260
  route_status: z2.enum(ISSUE_ROUTE_STATUSES).describe("Optional: only open issues whose route is in this state. live: a running session holds " + "the role or is the routed session. no_holder: nobody running holds the role, or the " + "session is not running, right now. unknown: the Envoy listener did not answer.").optional(),
14260
14261
  limit: z2.number({ int: true, min: 1, max: MAX_ISSUE_PAGE_LIMIT }).describe(`Maximum rows, 1-${MAX_ISSUE_PAGE_LIMIT}; default ${DEFAULT_ISSUE_PAGE_LIMIT}.`).optional(),
14261
- offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional()
14262
+ offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional(),
14263
+ all: z2.boolean().describe("Take every matching issue in one unpaged read, so no row is missed or repeated while " + "the list changes. Refused with limit or offset.").optional()
14262
14264
  })
14263
14265
  },
14264
14266
  {
@@ -14280,11 +14282,19 @@ var dispatchToolSpecs = [
14280
14282
  },
14281
14283
  {
14282
14284
  name: "dispatch_architecture_sync",
14283
- example: { project: "CORE" },
14284
- description: "Import a project's architecture model from its configured source repository now, instead of " + "waiting for the server's five-minute schedule. Returns the imported commit and component " + "count, or the recorded error when the model was rejected (the previous model stays up). " + "The source itself is configured by a human in Settings; 404 SOURCE_NOT_FOUND without one.",
14285
+ example: { project: "CORE", repo: "acme/infra" },
14286
+ description: "Import a project's architecture model from its source repositories now, instead of waiting " + "for the server's five-minute schedule. A project may have several sources, one per " + "repository, and its model is every source's components together. Without repo every " + "source imports and each answers on its own line; with repo only that source does. Each " + "line names the imported commit, or the recorded error when that source's model was " + "rejected (its previous components stay up, and every other source is untouched). Sources " + "are saved in Settings or with PUT /api/v1/projects/{key}/architecture-sources/{owner}/{repo}; " + "404 SOURCE_NOT_FOUND when the project has none, or none for that repository.",
14285
14287
  arguments: (z2) => ({
14286
- project: z2.string().describe("Project key whose architecture source to sync, such as CORE.")
14288
+ project: z2.string().describe("Project key whose architecture sources to sync, such as CORE."),
14289
+ repo: z2.string().describe("One source repository of the project to sync alone, owner/name; omit it to sync every source.").optional()
14287
14290
  }),
14291
+ validation: {
14292
+ check: (value) => {
14293
+ const { repo } = value;
14294
+ return typeof repo !== "string" || ARCHITECTURE_SOURCE_REPO_PATTERN.test(repo);
14295
+ },
14296
+ message: "repo is not a repository: give it as owner/name, such as acme/infra."
14297
+ },
14288
14298
  strict: true
14289
14299
  },
14290
14300
  {
@@ -15232,7 +15242,7 @@ var envoyToolSpecs = [
15232
15242
  },
15233
15243
  {
15234
15244
  name: "envoy_publish",
15235
- description: `Publish an Envoy message to a notifications. topic. A role or built-in topic is delivered as its guide says; any other notifications. topic you name (a team topic such as notifications.team.deploy-squad.all) is live only: it reaches every subscribed session that holds its own NATS connection, yours included, and is not retained. A session the listener delivers to over a serve port, such as an OpenCode agent, receives nothing on such a topic and is told nothing (dispatch://INFRA-122). ${DELIVERY_CONTRACT}`,
15245
+ description: `Publish an Envoy message to a notifications. topic. A role or built-in topic is delivered as its guide says; any other notifications. topic you name (a team topic such as notifications.team.deploy-squad.all) is live only: it reaches every session subscribed to it when you publish, and is not retained. A session that holds its own NATS connection receives it directly, yours included; a session the listener delivers to over a serve port, such as an OpenCode agent, receives it through its machine's listener when that listener subscribes to every topic (ENVOY_SUBSCRIBE_ALL_TOPICS=1; the shared hub's listener does not). ${DELIVERY_CONTRACT}`,
15236
15246
  arguments: (schema) => ({ topic: schema.string(), ...messageArguments(schema) }),
15237
15247
  operation: EnvoyToolOperation.publish
15238
15248
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "8.30.2",
3
+ "version": "8.31.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -136,7 +136,7 @@ EOF
136
136
 
137
137
  - Attach every architectural issue to the components it changes. Attach the root before you decompose it: children inherit.
138
138
  - `--components-json '{"mode":"explicit","ids":["web"]}'`, `'{"mode":"none","reason":"hiring, not code"}'` (only for non-architectural work, with a concrete reason), or `'{"mode":"inherit"}'`.
139
- - Component ids: each non-external `components[].id` from `GET /api/v1/projects/{key}/architecture`, or the file names under `.dispatch/architecture/` in its source repository. Others are refused `COMPONENTS_INPUT`. A closed issue can be classified without reopening.
139
+ - Component ids: each non-external `components[].id` from `GET /api/v1/projects/{key}/architecture`, or the file names under `.dispatch/architecture/` in any of the project's source repositories. A project may import from several; ids are project-wide, so one issue can name components of different sources. Others are refused `COMPONENTS_INPUT`. A closed issue can be classified without reopening.
140
140
  - Change code and `.dispatch/architecture/<id>.md` in the same review. A stale model: `dispatch architecture-sync` ([issues.md](skill://dispatch/references/issues.md#syncing-a-projects-architecture-model)).
141
141
 
142
142
  ## Close what you opened
@@ -63,4 +63,4 @@ As a surface's owner, run `dispatch issues --project <KEY> --priority 0 --priori
63
63
 
64
64
  ## Syncing a project's architecture model
65
65
 
66
- `dispatch architecture-sync --project CORE` imports the model now. It prints the commit, or the error, and keeps the old model. No source configured answers `SOURCE_NOT_FOUND`.
66
+ A project imports its model from one or more source repositories, each on its own branch. `dispatch architecture-sync --project CORE` imports every source now and prints a line for each: `Synced CORE architecture from <repo>@<branch>: commit <sha> (<time>).`, or `Sync failed for CORE (<repo>@<branch>): <error>` followed by what of that source still shows: its commit, its component count when the source was re-saved on another branch and has not imported from it yet, or that none ever imported. A failed source keeps its previous components; every other source is untouched. `--repo <owner/name>` imports that one source alone. No source configured, or no source from that repository, answers `SOURCE_NOT_FOUND`. A credential or branch problem is that source's failed line, or with `--repo` an error, `SOURCE_ACCESS`.
@@ -66,6 +66,6 @@ Every topic starts with `notifications.`.
66
66
 
67
67
  - Send to one session with `envoy_send`, to the one session that holds a named duty with a role, and to a group with a team topic.
68
68
  - Publish to a role with `envoy_publish(topic="notifications.role.<role>", ...)`; never subscribe to a role topic, because the listener forwards a role message to its holder only. A publish returns the live `holder`; an unheld role returns an error. `envoy_role_get(role=...)` finds the holder first.
69
- - For a team, the group subscribes to `notifications.team.<name>.>`, and any member publishes to `notifications.team.<name>.all`. Envoy treats any other `notifications.` topic that no source in the table uses the same way. It reaches only the sessions subscribed when it is published (yours too, when you subscribe), and is not kept for a session that subscribes later. A topic outside `notifications.` is refused.
69
+ - For a team, the group subscribes to `notifications.team.<name>.>`, and any member publishes to `notifications.team.<name>.all`. Envoy treats any other `notifications.` topic that no source in the table uses the same way. It reaches only the sessions subscribed when it is published (yours too, when you subscribe, unless you are a serve-port session, which receives it only through a listener run with `ENVOY_SUBSCRIBE_ALL_TOPICS=1` and never its own publish), and is not kept for a session that subscribes later. A topic outside `notifications.` is refused.
70
70
  - How a role claim survives a resume, a fork or a dead process: `skill://envoy/references/roles.md`.
71
71
  - In a Legion pane, the plugin claims your role at session start; read `skill://envoy/references/legion.md` before you claim, release or publish to a role, or answer a role delivery failure there.
@@ -54,7 +54,7 @@ envoy:
54
54
  It depends on where a session takes its deliveries, not on whether the topic is built in or your own.
55
55
 
56
56
  - A session that holds its own NATS connection (every Oh My Pi session, and a Claude Code one) receives every message published on a subject it subscribes to, whoever sent it: its own publish included, and a subagent's publish to a topic the parent subscribes to. This holds for a topic the stream keeps and for a live team topic alike.
57
- - A session the listener delivers to by push, over a serve port (such as the OpenCode plugin), does not receive its own publish, and receives no team topic at all, since it holds no NATS subscription of its own.
57
+ - A session the listener delivers to by push, over a serve port (such as the OpenCode plugin), does not receive its own publish, and receives a team topic only through the listener that owns it when that listener runs with `ENVOY_SUBSCRIBE_ALL_TOPICS=1` (the shared hub's listener does not).
58
58
  - A role is not a subject its holder subscribes to: the listener finds the live holder and forwards the message to it, and drops the message when that holder is the message's source session.
59
59
 
60
60
  ## Listener API errors
@@ -120,8 +120,9 @@ The merge closes the issue: when GitHub reports the pull request merged while th
120
120
  `pr-merged` notice names what it waits for and none follows as they depart, so read the pull
121
121
  request on GitHub.
122
122
  - A child's close reaches you as `child-closed`. The daemon retires five of its six claims and
123
- suspends its implementer, session and volume kept, because it still owes the production check
124
- (`skill://legion-worker/references/merge-gate.md`, *The production check after the close*).
123
+ leaves the implementer running, because it still owes the production check. When the issue's
124
+ pod and Sandbox go is in `skill://legion-worker/references/merge-gate.md`, *The production check
125
+ after the close*.
125
126
  - A root's close writes `done`, frees the tree's admission slot and lingers the tree for
126
127
  `linger_hours` with every member suspended. The linger's end, the tree's last step, waits until
127
128
  every member's open production check has closed.
@@ -61,17 +61,21 @@ repositories with your gh's read-only access (`-R owner/repo`).
61
61
 
62
62
  ## Architecture components
63
63
 
64
- A project's architecture source is the repository whose `.dispatch/architecture/` files Dispatch
65
- imports as its components (`skill://dispatch`, "Architecture components").
64
+ A project's architecture sources are the repositories whose `.dispatch/architecture/` files
65
+ Dispatch imports as its components (`skill://dispatch`, "Architecture components").
66
66
 
67
67
  - **The components exist:** attach the root before you decompose, so every child inherits it:
68
68
  `dispatch issue-update --issue <root> --components-json '{"mode":"explicit","ids":[…]}'`.
69
69
  - **They do not exist yet (bootstrap):** in the root's first PR (the implement worker pushes it),
70
70
  write `.dispatch/architecture/<id>.md` files for the planned components — front matter `title`,
71
- `parent`, `depends_on`, `external`; no `paths` yet. The importer reads the source branch's head,
72
- so leave the root on `inherit` until that PR has merged: an attach before then answers
71
+ `parent`, `depends_on`, `external`; no `paths` yet. A project may have several sources, one per
72
+ repository, and its model is all of their components together: the file goes in the branch of
73
+ the source whose repository holds it, `parent` and `depends_on` name components of that same
74
+ source only, and its id must be one no other source of the project uses (a repeated id fails that
75
+ source's import, naming the repository that has it). The importer reads the source branch's
76
+ head, so leave the root on `inherit` until that PR has merged: an attach before then answers
73
77
  `400 COMPONENTS_INPUT`, since the component does not exist. After the merge, run
74
- `dispatch architecture-sync --project <KEY>` and attach the root; children created before then
75
- inherit it.
78
+ `dispatch architecture-sync --project <KEY>` (every source; `--repo <owner/name>` syncs that one
79
+ alone) and attach the root; children created before then inherit it.
76
80
  - Give a child its own `--components-json` only when it changes a narrower set, and
77
81
  `{"mode":"none","reason":"…"}` when it changes no code or file a component describes.
@@ -11,7 +11,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
11
11
 
12
12
  ## Every phase, in this order
13
13
 
14
- 1. **Read first.** Read the issue, then each committed handoff before your phase, with `read` from `$LEGION_WORKSPACE/.legion/<issue>/`, in order: `plan.json`, `implement.json`, `test.json`, `review.json`. Where one disagrees with your memory or a transcript, the file wins: it is the copy that survived. At each later assignment read them again, your own included, since other phases may have committed since. After a `Your workspace was recreated…` message, read `.legion/<issue>/workspace-recovered.json` too, once.
14
+ 1. **Read first.** Read the issue, then each committed handoff before your phase, with `read` from `$LEGION_WORKSPACE/.legion/<issue>/`, in order: `plan.json`, `implement.json`, `test.json`, `review.json`. Where one disagrees with your memory or a transcript, the file wins: it is the copy that survived. At each later assignment read them again, your own included, since other phases may have committed since. After a `Your workspace was recreated…` message, anything unpushed is gone, and no file in the workspace says so.
15
15
  2. **Commit only your phase's work**, with `jj -R "$LEGION_WORKSPACE" split -m "<message>" <paths…>`, each commit holding only your phase's paths, so the next phase sees what you changed.
16
16
  3. **Put back what you copied in.** Before you write the handoff, run `jj -R "$LEGION_WORKSPACE" status` and remove or restore every file you copied or fetched in that is not your phase's work, such as another branch's test rig. With jj the working copy is the commit: the next role's first commit builds on what you left, and its push ships it.
17
17
  4. **Write the handoff** your role prompt names. Each `proof` entry, the implementer's or the tester's, is an object of six non-empty strings: `criterion` (the acceptance line it proves), `surface` (where a user reaches the change), `command`, `observed`, `headSha` (the commit it ran at) and `negativeControl` (a deliberately broken input and the refusal seen); `skill://legion-worker/references/pr-body.md#what-a-proof-is` defines each. To add to a handoff already on disk, `read` it, edit it, and `write` it whole with `completed` set to now.
@@ -251,16 +251,17 @@ reviewing until you finish.
251
251
  topic, and file no issue. It names this issue, the pull request, the merge commit, what you
252
252
  drove and what failed. Name the ask (`dispatch://<REPORT KEY>/ask/<id>`) in the `Production:`
253
253
  line and in the summary.
254
- - **Nothing waits in the session for a deploy.** A `pending` ends the look: the daemon suspends
255
- your claim with its session and the issue's volume kept, the pod goes, and the daemon wakes the
256
- check (the pod comes back and your session resumes with a new check task) on Dispatch's
254
+ - **Nothing waits in the session for a deploy.** A `pending` ends the look. Where no wake is
255
+ queued yet, the daemon suspends your claim with its session kept, and the issue's Sandbox goes
256
+ with its pod and disk; where a wake arrived while the look was open, the next look starts at once
257
+ on the same pod. Either way the daemon wakes the check (after a suspension a new pod comes back
258
+ on a fresh disk, and your session resumes with a new check task) on Dispatch's
257
259
  `issue.deployed` event for the pull request; when the project's `legion.yaml` names the
258
260
  repository's deploy workflow (`projects.<KEY>.deploys`), at each completed run of that workflow
259
261
  on the default branch whose named step (such as `Apply`) succeeded after the merge, once per such
260
262
  job; or, for `sjawhar/legion`, on a Legion daemon's boot on a build containing the merge, on
261
263
  Dispatch's `server.started` naming such a build, or on the build the daemon read from Dispatch at
262
- its boot. A wake while a look is open is kept, and a `pending` then starts the next look at once.
263
- The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
264
+ its boot. The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
264
265
  `the deploy workflow's run: <repo> <workflow> run <id> attempt <n>, job <job name>: <step> succeeded at <t>`,
265
266
  `the Legion daemon's boot on build <sha>`, `Dispatch's start on build <sha>`,
266
267
  `Dispatch's build <sha>`), and its `Production:` line names it too, so the pull request carries
@@ -279,5 +280,6 @@ reviewing until you finish.
279
280
  on a closed issue (`ISSUE_CLOSED`), so the deployment instructions' "on the pull request and the
280
281
  issue" is the pull request for a closed issue, and a defect reaches Dispatch as the ask above.
281
282
  The architect accepted the issue before the merge and is asked nothing after it. `pass` or
282
- `fail` closes the check: your claim is retired and, for a child, the issue's Sandbox and volume
283
- are released.
283
+ `fail` closes the check: your claim is retired and the issue's Sandbox goes with its pod and
284
+ disk, for a root as for a child. Neither ending retires or suspends anything of an issue back in
285
+ the workflow (a root reopened inside its linger): its claim and pod are the new run's.
@@ -86,11 +86,11 @@ for it*.
86
86
 
87
87
  An install of your own — an interpreter, a browser, a second toolchain, an extraction prefix — is
88
88
  on the sanctioned side when both of these hold: what it writes lands on the issue's own volume,
89
- under `/legion`, which outlives the pod and is shared by the issue's roles — whether through a
90
- variable the runtime sets for you there or under a path on that volume your harness names
91
- itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under `/legion/workspaces`) and
92
- the runtime's own directories (`/legion/repos`, `/legion/feed`, `/legion/sessions`); and after
93
- the command exits nothing else resolves differently. The workspace is excluded because its jj
89
+ under `/legion`, which outlives a role's relaunch in its pod and is shared by the issue's roles —
90
+ whether through a variable the runtime sets for you there or under a path on that volume your
91
+ harness names itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under
92
+ `/legion/workspaces`) and the runtime's own directories (`/legion/repos`, `/legion/feed`); and
93
+ after the command exits nothing else resolves differently. The workspace is excluded because its jj
94
94
  working copy tracks every new file under jj's `snapshot.max-new-file-size` (1 MiB in a role
95
95
  container), so a root written there is added to the change every role shares, and a larger new
96
96
  file is left untracked, every jj command in the workspace then warning `Refused to snapshot some
@@ -122,7 +122,7 @@ What each shape leaves resolving:
122
122
  all. `uv sync` or `bun install` in your workspace materialises what that project already
123
123
  declares, into that project's own directory, which a process reaches by asking for that project
124
124
  and no other way — and the runtime puts `UV_PYTHON_INSTALL_DIR` and the workspace itself on the
125
- issue's volume exactly so a later pod reuses it.
125
+ issue's volume exactly so every role of the issue's pod reuses it.
126
126
  - An extraction prefix: whatever you leave resolving through it, which is yours to decide. Unpack
127
127
  Debian packages under `$HOME`, export `LD_LIBRARY_PATH` in the shell you go on working in, and
128
128
  you change what every later command in that shell — and every subagent that inherits the
@@ -147,8 +147,8 @@ toolchain — is where the first condition does the work. `uv run --with pyyaml=
147
147
  passes both: the wheel lands in `UV_CACHE_DIR`, under `wheels-v6/pypi/pyyaml/6.0.2-…`, and
148
148
  `UV_PYTHON_INSTALL_DIR` is never created, because the image's own `python3` served the run. The
149
149
  interpreter is the other run: a project whose `.python-version` names an interpreter uv must fetch
150
- puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where the issue's next pod finds it, while the
151
- cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
150
+ puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where every role of the issue's pod finds it, while
151
+ the cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
152
152
  repository's own Dispatch suite installs (`playwright install`, `packages/dispatch`'s
153
153
  `e2e:install`) — it passes as it stands, with nothing pointed anywhere: playwright-core resolves
154
154
  its browser registry under `XDG_CACHE_HOME`, which the runtime has already put on the issue's
@@ -164,11 +164,13 @@ here) at a directory under one of the paths above yourself, since an unset one l
164
164
  wherever the tool defaults, which is nowhere this runtime chose.
165
165
 
166
166
  Where it lands is what you pay. Under one of those variables it is on the issue's volume: it
167
- outlives the pod, and every role of the issue shares it — under every one of those variables but
168
- `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, "the one directory of the issue's
169
- scratch that is the role's own rather than the issue's" (`roleEnvironment`), because Oh My Pi
170
- names its browser broker's lock from the state root and an abstract socket name is one name across
171
- every container of the pod — and a later pod of the issue reuses it. Under `$HOME` —
167
+ outlives a role's relaunch in its pod, and every role of the issue shares it — under every one of
168
+ those variables but `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, "the one directory
169
+ of the issue's scratch that is the role's own rather than the issue's" (`roleEnvironment`),
170
+ because Oh My Pi names its browser broker's lock from the state root and an abstract socket name
171
+ is one name across every container of the pod — though not the pod itself: the issue's disk is
172
+ the pod's own and is deleted with it, so a later pod of the issue starts without it. Under
173
+ `$HOME` —
172
174
  `/home/legion`, which no volume is mounted on — it is the container's own filesystem: one copy per
173
175
  role, per pod, gone when the role relaunches, and charged to the container's ephemeral-storage
174
176
  limit, which is per role and smaller than the volume. Moving exactly these writes off that
@@ -178,7 +180,7 @@ under `$HOME` fails the first condition and is outside the sanctioned form, howe
178
180
  changes. The three artefacts above are the whole of that line's ground. Disk is not: every cache is
179
181
  already relocated onto the issue's volume, which has the room for them (`docs/kubernetes.md`, "The
180
182
  tools a role's own work needs", carries the figure), and a sanctioned install is paid once per
181
- issue, on the issue's own disk, by the issue's roles together.
183
+ issue pod, on the issue's own disk, by the issue's roles together.
182
184
 
183
185
  **The worked case, and the one a role actually meets: the Dispatch e2e stack.** A change to
184
186
  `packages/dispatch` is proved by driving its browser suite, which needs a Postgres with pgvector;