@sjawhar/pi-legion 8.15.0 → 8.16.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/legion.js CHANGED
@@ -13,10 +13,11 @@ var __export = (target, all) => {
13
13
  set: __exportSetter.bind(all, name)
14
14
  });
15
15
  };
16
+ var __require = import.meta.require;
16
17
 
17
18
  // extensions/legion.ts
18
19
  import { randomUUID } from "crypto";
19
- import path4 from "path";
20
+ import path5 from "path";
20
21
 
21
22
  // ../envoy-client/src/dispatch-config.ts
22
23
  import { readFileSync as readFileSync2 } from "fs";
@@ -13559,6 +13560,9 @@ config(en_default());
13559
13560
  function messageFor(error48) {
13560
13561
  return error48 instanceof Error ? error48.message : String(error48);
13561
13562
  }
13563
+ function hasErrnoCode(error48, code) {
13564
+ return error48 instanceof Error && "code" in error48 && error48.code === code;
13565
+ }
13562
13566
 
13563
13567
  // ../envoy-client/src/secret-file.ts
13564
13568
  import { readFileSync } from "fs";
@@ -15219,6 +15223,341 @@ function subagentSessionCheck() {
15219
15223
  // extensions/legion.ts
15220
15224
  import { logger as logger3 } from "@oh-my-pi/pi-utils";
15221
15225
 
15226
+ // src/capability-report.ts
15227
+ import { readdir, stat } from "fs/promises";
15228
+ import path3 from "path";
15229
+ var CAPABILITY_CHECK_TIMEOUT_MS = 8000;
15230
+ var CAPABILITY_REPORT_TIMEOUT_MS = 1e4;
15231
+ var WEB_SEARCH_PROBE_QUERY = "jujutsu version control";
15232
+ var ENVOY_TOOL_NAMES = [
15233
+ "envoy_subscribe",
15234
+ "envoy_unsubscribe",
15235
+ "envoy_list",
15236
+ "envoy_inbox",
15237
+ "envoy_send",
15238
+ "envoy_publish",
15239
+ "envoy_role_set",
15240
+ "envoy_role_get",
15241
+ "envoy_whoami",
15242
+ "envoy_sessions"
15243
+ ];
15244
+ var LIVE_CAPABILITIES = [
15245
+ "subagents",
15246
+ "web-search",
15247
+ "mcp",
15248
+ "repository-extensions",
15249
+ "dispatch-envoy-tools",
15250
+ "github"
15251
+ ];
15252
+ var DEFAULT_BUDGETS = {
15253
+ checkMs: CAPABILITY_CHECK_TIMEOUT_MS,
15254
+ reportMs: CAPABILITY_REPORT_TIMEOUT_MS
15255
+ };
15256
+ var ok = (detail) => ({ ok: true, detail });
15257
+ var failed = (detail) => ({ ok: false, detail });
15258
+ function firstLine(text) {
15259
+ return text.trim().split(`
15260
+ `, 1)[0] ?? "";
15261
+ }
15262
+ function commandFailure(result) {
15263
+ return firstLine(result.stderr) || `exit code ${result.code ?? "none"}`;
15264
+ }
15265
+ function whichOnPath(name) {
15266
+ return Bun.which(name, { PATH: process.env.PATH ?? "" }) ?? name;
15267
+ }
15268
+ var checkSubagents = async (host2, input) => {
15269
+ if (!input.agentsExposed)
15270
+ return failed("pi.agents is not exposed");
15271
+ if (!input.activeTools.includes("task"))
15272
+ return failed("task is not a registered tool");
15273
+ const [discovered, disabled] = await Promise.all([
15274
+ host2.discoverAgents(input.cwd),
15275
+ host2.disabledAgents()
15276
+ ]);
15277
+ const names = new Set(discovered.agents.map((agent) => agent.name));
15278
+ const missing = input.promptAgents.filter((agent) => !names.has(agent));
15279
+ const off = input.promptAgents.filter((agent) => disabled.includes(agent));
15280
+ const problems = [];
15281
+ if (missing.length > 0) {
15282
+ problems.push(`agents the prompts dispatch that the task tool does not resolve: ${missing.join(", ")}`);
15283
+ }
15284
+ if (off.length > 0)
15285
+ problems.push(`disabled by task.disabledAgents: ${off.join(", ")}`);
15286
+ if (problems.length > 0)
15287
+ return failed(problems.join("; "));
15288
+ return ok(`task resolves ${names.size} agents, every one the prompts dispatch`);
15289
+ };
15290
+ var checkWebSearch = async (host2, input, signal) => {
15291
+ if (!input.activeTools.includes("web_search")) {
15292
+ return failed("web_search is not a registered tool");
15293
+ }
15294
+ const started = Date.now();
15295
+ const result = await host2.runSearchQuery({ query: WEB_SEARCH_PROBE_QUERY, limit: 1 }, { sessionId: input.sessionId, signal });
15296
+ const elapsed = Date.now() - started;
15297
+ const details = result.details ?? {};
15298
+ if (details.error !== undefined && details.error !== null) {
15299
+ return failed(`web_search failed: ${String(details.error)}`);
15300
+ }
15301
+ const provider = details.response?.provider ?? details.provider;
15302
+ if (provider === undefined || provider === null || provider === "none") {
15303
+ return failed("web_search failed: no provider answered");
15304
+ }
15305
+ const text = result.content?.[0]?.text ?? "";
15306
+ if (text.startsWith("Error"))
15307
+ return failed(`web_search failed: ${firstLine(text)}`);
15308
+ return ok(`web_search registered; provider ${String(provider)} answered in ${elapsed} ms`);
15309
+ };
15310
+ var URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi;
15311
+ function mcpErrorFor(errors3, name) {
15312
+ let quoted;
15313
+ for (const entry of errors3) {
15314
+ if (typeof entry !== "object" || entry === null) {
15315
+ if (!String(entry).includes(name))
15316
+ continue;
15317
+ quoted = String(entry);
15318
+ break;
15319
+ }
15320
+ const record2 = entry;
15321
+ const names = [record2.server, record2.name, record2.path];
15322
+ if (!names.some((value) => value === name || value === `mcp:${name}`))
15323
+ continue;
15324
+ const message = record2.error ?? record2.message;
15325
+ quoted = message === undefined ? String(entry) : String(message);
15326
+ break;
15327
+ }
15328
+ return quoted?.replace(URL_USERINFO, "$1***@");
15329
+ }
15330
+ var checkMCP = async (host2, input, signal) => {
15331
+ const { configs, sources } = await host2.loadMCPConfigs(input.cwd);
15332
+ const configured = Object.keys(configs);
15333
+ if (configured.length === 0)
15334
+ return ok("no MCP server configured");
15335
+ const { manager, errors: errors3 } = await host2.discoverMCPServers(input.cwd);
15336
+ let torn;
15337
+ const tearDown = () => {
15338
+ torn ??= manager.disconnectAll().catch(() => {
15339
+ return;
15340
+ });
15341
+ return torn;
15342
+ };
15343
+ signal.addEventListener("abort", tearDown, { once: true });
15344
+ if (signal.aborted)
15345
+ tearDown();
15346
+ try {
15347
+ await manager.waitForPendingConnections();
15348
+ const connected = [];
15349
+ const unconnected = [];
15350
+ for (const name of configured) {
15351
+ const status = manager.getConnectionStatus(name);
15352
+ if (status === "connected") {
15353
+ const source = sources[name]?.path;
15354
+ connected.push(source === undefined ? name : `${name} (${path3.relative(input.cwd, source)})`);
15355
+ } else {
15356
+ unconnected.push(`${name} (${mcpErrorFor(errors3, name) ?? `status ${status}`})`);
15357
+ }
15358
+ }
15359
+ if (unconnected.length === 0)
15360
+ return ok(`connected: ${connected.join(", ")}`);
15361
+ const suffix = connected.length === 0 ? "" : `; connected: ${connected.join(", ")}`;
15362
+ return failed(`not connected: ${unconnected.join(", ")}${suffix}`);
15363
+ } finally {
15364
+ signal.removeEventListener("abort", tearDown);
15365
+ await tearDown();
15366
+ }
15367
+ };
15368
+ var EXTENSION_MODULE = /\.(?:ts|js|mjs|cjs)$/;
15369
+ var EXTENSION_INDEXES = ["index.ts", "index.js", "index.mjs", "index.cjs"];
15370
+ var SKILL_DIRECTORIES = [path3.join(".omp", "skills"), path3.join(".claude", "skills")];
15371
+ async function namesIn(directory) {
15372
+ try {
15373
+ return await readdir(directory);
15374
+ } catch (error48) {
15375
+ if (hasErrnoCode(error48, "ENOENT") || hasErrnoCode(error48, "ENOTDIR"))
15376
+ return [];
15377
+ throw error48;
15378
+ }
15379
+ }
15380
+ async function kindOf(file2) {
15381
+ try {
15382
+ const stats = await stat(file2);
15383
+ return stats.isFile() ? "file" : stats.isDirectory() ? "directory" : undefined;
15384
+ } catch (error48) {
15385
+ if (hasErrnoCode(error48, "ENOENT") || hasErrnoCode(error48, "ENOTDIR"))
15386
+ return;
15387
+ throw error48;
15388
+ }
15389
+ }
15390
+ async function repositoryEntries(cwd) {
15391
+ const entries = [];
15392
+ const extensions = path3.join(cwd, ".omp", "extensions");
15393
+ for (const name of await namesIn(extensions)) {
15394
+ const candidate = path3.join(extensions, name);
15395
+ const kind = await kindOf(candidate);
15396
+ if (kind === "file" && EXTENSION_MODULE.test(name)) {
15397
+ entries.push({ path: candidate, directory: false });
15398
+ } else if (kind === "directory") {
15399
+ const indexes = await Promise.all(EXTENSION_INDEXES.map((index) => kindOf(path3.join(candidate, index))));
15400
+ if (indexes.includes("file"))
15401
+ entries.push({ path: candidate, directory: true });
15402
+ }
15403
+ }
15404
+ for (const skills of SKILL_DIRECTORIES) {
15405
+ for (const name of await namesIn(path3.join(cwd, skills))) {
15406
+ const skill = path3.join(cwd, skills, name, "SKILL.md");
15407
+ if (await kindOf(skill) === "file")
15408
+ entries.push({ path: skill, directory: false });
15409
+ }
15410
+ }
15411
+ return entries;
15412
+ }
15413
+ var checkRepositoryExtensions = async (host2, input) => {
15414
+ const entries = await repositoryEntries(input.cwd);
15415
+ if (entries.length === 0) {
15416
+ return ok("the repository carries no .omp/extensions, .omp/skills or .claude/skills");
15417
+ }
15418
+ const [extensionPaths, loaded] = await Promise.all([
15419
+ host2.discoverExtensionPaths(input.cwd),
15420
+ host2.loadSkills(input.cwd)
15421
+ ]);
15422
+ const discovered = new Set([...extensionPaths, ...loaded.skills.map((skill) => skill.filePath)].map((file2) => path3.resolve(file2)));
15423
+ const isDiscovered = (entry) => {
15424
+ const resolved = path3.resolve(entry.path);
15425
+ if (discovered.has(resolved))
15426
+ return true;
15427
+ if (!entry.directory)
15428
+ return false;
15429
+ const prefix = resolved + path3.sep;
15430
+ return [...discovered].some((file2) => file2.startsWith(prefix));
15431
+ };
15432
+ const relative = (selected) => selected.map((entry) => path3.relative(input.cwd, entry.path)).sort().join(", ");
15433
+ const found = [];
15434
+ const lost = [];
15435
+ for (const entry of entries)
15436
+ (isDiscovered(entry) ? found : lost).push(entry);
15437
+ if (lost.length === 0)
15438
+ return ok(`discovered: ${relative(found)}`);
15439
+ const suffix = found.length === 0 ? "" : `; discovered: ${relative(found)}`;
15440
+ return failed(`not discovered: ${relative(lost)}${suffix}`);
15441
+ };
15442
+ var checkDispatchEnvoyTools = async (host2, input, signal) => {
15443
+ const missing = ENVOY_TOOL_NAMES.filter((tool) => !input.activeTools.includes(tool));
15444
+ if (missing.length > 0)
15445
+ return failed(`Envoy tools missing: ${missing.join(", ")}`);
15446
+ const result = await host2.run("dispatch", ["read", "--issue", input.issue], {
15447
+ cwd: input.cwd,
15448
+ signal
15449
+ });
15450
+ if (result.code !== 0)
15451
+ return failed(`dispatch read failed: ${commandFailure(result)}`);
15452
+ return ok(`ten Envoy tools registered; dispatch read ${input.issue} answered from ${whichOnPath("dispatch")}`);
15453
+ };
15454
+ var checkGitHub = async (host2, input, signal) => {
15455
+ const result = await host2.run("gh", ["api", "graphql", "-f", "query={viewer{login}}", "--jq", ".data.viewer.login"], { cwd: input.cwd, signal });
15456
+ if (result.code !== 0)
15457
+ return failed(`gh viewer login failed: ${commandFailure(result)}`);
15458
+ return ok(`gh viewer login: ${result.stdout.trim()}`);
15459
+ };
15460
+ var CHECKS = {
15461
+ subagents: checkSubagents,
15462
+ "web-search": checkWebSearch,
15463
+ mcp: checkMCP,
15464
+ "repository-extensions": checkRepositoryExtensions,
15465
+ "dispatch-envoy-tools": checkDispatchEnvoyTools,
15466
+ github: checkGitHub
15467
+ };
15468
+ function budgetLabel(ms) {
15469
+ return ms % 1000 === 0 ? `${ms / 1000} s` : `${ms} ms`;
15470
+ }
15471
+ async function withinBudget(work, ms, onDeadline) {
15472
+ const deadline = Promise.withResolvers();
15473
+ const timer = setTimeout(() => deadline.resolve(onDeadline()), ms);
15474
+ try {
15475
+ return await Promise.race([work, deadline.promise]);
15476
+ } finally {
15477
+ clearTimeout(timer);
15478
+ }
15479
+ }
15480
+ async function measureCapabilities(host2, input, budgets = DEFAULT_BUDGETS) {
15481
+ const started = Date.now();
15482
+ const measuredAt = new Date(started).toISOString();
15483
+ const outcomes = new Map;
15484
+ const controllers = [];
15485
+ const checks3 = LIVE_CAPABILITIES.map(async (name) => {
15486
+ const controller = new AbortController;
15487
+ controllers.push(controller);
15488
+ const outcome = await withinBudget(CHECKS[name](host2, input, controller.signal).catch((error48) => failed(firstLine(messageFor(error48)))), budgets.checkMs, () => {
15489
+ controller.abort();
15490
+ return failed(`did not finish within ${budgetLabel(budgets.checkMs)}`);
15491
+ });
15492
+ outcomes.set(name, outcome);
15493
+ });
15494
+ await withinBudget(Promise.all(checks3).then(() => {
15495
+ return;
15496
+ }), budgets.reportMs, () => {
15497
+ return;
15498
+ });
15499
+ for (const controller of controllers)
15500
+ controller.abort();
15501
+ const rows = LIVE_CAPABILITIES.map((name) => ({
15502
+ name,
15503
+ ...outcomes.get(name) ?? failed(`did not finish within ${budgetLabel(budgets.reportMs)}`)
15504
+ }));
15505
+ return { measuredAt, elapsedMs: Date.now() - started, rows };
15506
+ }
15507
+ async function runCommand(command, args, options) {
15508
+ const child = Bun.spawn([command, ...args], {
15509
+ cwd: options.cwd,
15510
+ env: process.env,
15511
+ stdin: "ignore",
15512
+ stdout: "pipe",
15513
+ stderr: "pipe",
15514
+ signal: options.signal
15515
+ });
15516
+ const [stdout, stderr] = await Promise.all([
15517
+ new Response(child.stdout).text(),
15518
+ new Response(child.stderr).text(),
15519
+ child.exited
15520
+ ]);
15521
+ return { code: child.exitCode, stdout, stderr };
15522
+ }
15523
+ function ompCapabilityHost() {
15524
+ const hostSettings = async () => (await import("@oh-my-pi/pi-coding-agent/config/settings")).settings;
15525
+ return {
15526
+ discoverAgents: async (cwd) => (await import("@oh-my-pi/pi-coding-agent")).discoverAgents(cwd),
15527
+ disabledAgents: async () => {
15528
+ const [{ cfgTaskDisabledAgents }, settings] = await Promise.all([
15529
+ import("@oh-my-pi/pi-coding-agent/task/settings"),
15530
+ hostSettings()
15531
+ ]);
15532
+ return cfgTaskDisabledAgents.get(settings);
15533
+ },
15534
+ runSearchQuery: async (args, options) => (await import("@oh-my-pi/pi-coding-agent")).runSearchQuery(args, options),
15535
+ loadMCPConfigs: async (cwd) => (await import("@oh-my-pi/pi-coding-agent/mcp/config")).loadAllMCPConfigs(cwd),
15536
+ discoverMCPServers: async (cwd) => (await import("@oh-my-pi/pi-coding-agent")).discoverMCPServers(cwd),
15537
+ discoverExtensionPaths: async (cwd) => {
15538
+ const [{ discoverExtensionPaths }, { cfgDisabledExtensions }, settings] = await Promise.all([
15539
+ import("@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"),
15540
+ import("@oh-my-pi/pi-coding-agent/extensibility/settings"),
15541
+ hostSettings()
15542
+ ]);
15543
+ return discoverExtensionPaths([], cwd, cfgDisabledExtensions.get(settings));
15544
+ },
15545
+ loadSkills: async (cwd) => {
15546
+ const [{ loadSkills }, { cfgDisabledExtensions, cfgSkills }, settings] = await Promise.all([
15547
+ import("@oh-my-pi/pi-coding-agent"),
15548
+ import("@oh-my-pi/pi-coding-agent/extensibility/settings"),
15549
+ hostSettings()
15550
+ ]);
15551
+ return loadSkills({
15552
+ ...cfgSkills.get(settings),
15553
+ cwd,
15554
+ disabledExtensions: cfgDisabledExtensions.get(settings)
15555
+ });
15556
+ },
15557
+ run: runCommand
15558
+ };
15559
+ }
15560
+
15222
15561
  // ../pi-shared/src/role-claim-bridge.ts
15223
15562
  function legionRoleClaimBridge() {
15224
15563
  return envoyPluginInterface().roleClaim;
@@ -15244,7 +15583,7 @@ function onEnvoyRoleRegained(listener) {
15244
15583
  // package.json
15245
15584
  var package_default = {
15246
15585
  name: "@sjawhar/pi-legion",
15247
- version: "8.15.0",
15586
+ version: "8.16.0",
15248
15587
  license: "Apache-2.0",
15249
15588
  type: "module",
15250
15589
  omp: {
@@ -15256,7 +15595,7 @@ var package_default = {
15256
15595
  ]
15257
15596
  },
15258
15597
  legion: {
15259
- daemonApiVersion: 20
15598
+ daemonApiVersion: 21
15260
15599
  },
15261
15600
  repository: {
15262
15601
  type: "git",
@@ -15394,10 +15733,17 @@ var legionLocator = exports_external.discriminatedUnion("runtime", [
15394
15733
  })
15395
15734
  })
15396
15735
  ]);
15736
+ var legionCapabilityReportView = exports_external.strictObject({
15737
+ measuredAt: timestamp,
15738
+ incarnation: exports_external.string(),
15739
+ ok: exports_external.array(nonEmptyString),
15740
+ open: exports_external.array(exports_external.strictObject({ name: nonEmptyString, detail: nonEmptyString }))
15741
+ });
15397
15742
  var legionClaimView = exports_external.strictObject({
15398
15743
  session: exports_external.string(),
15399
15744
  state: exports_external.enum(LEGION_CLAIM_STATES),
15400
- locator: legionLocator.optional()
15745
+ locator: legionLocator.optional(),
15746
+ capabilities: legionCapabilityReportView.optional()
15401
15747
  });
15402
15748
  var legionPhaseView = exports_external.strictObject({
15403
15749
  claim: legionClaimView,
@@ -15465,7 +15811,7 @@ var legionAgentSecretsLoginView = exports_external.strictObject({
15465
15811
  });
15466
15812
  var legionCapabilityState = exports_external.strictObject({
15467
15813
  name: nonEmptyString,
15468
- status: exports_external.enum(["present", "installed", "unchecked", "live", "withheld", "decided", "open"]),
15814
+ status: exports_external.enum(["present", "installed", "unchecked", "withheld", "decided", "open"]),
15469
15815
  detail: nonEmptyString,
15470
15816
  decision: nonEmptyString.optional(),
15471
15817
  configLine: nonEmptyString.optional()
@@ -15485,7 +15831,20 @@ var LegionRegisterResponse = exports_external.strictObject({
15485
15831
  issue: nonEmptyString,
15486
15832
  role: exports_external.enum(LEGION_ROLES),
15487
15833
  generation: exports_external.number().int().positive(),
15488
- secret: nonEmptyString
15834
+ secret: nonEmptyString,
15835
+ promptAgents: exports_external.array(nonEmptyString)
15836
+ });
15837
+ var LegionCapabilityReport = exports_external.strictObject({
15838
+ measuredAt: timestamp,
15839
+ elapsedMs: exports_external.number().int().nonnegative(),
15840
+ rows: exports_external.array(exports_external.strictObject({ name: nonEmptyString, ok: exports_external.boolean(), detail: exports_external.string() }))
15841
+ });
15842
+ var LegionReadyRequest = exports_external.strictObject({
15843
+ claimToken: nonEmptyString,
15844
+ sessionId: nonEmptyString,
15845
+ secret: nonEmptyString,
15846
+ generation: exports_external.number().int().positive(),
15847
+ capabilities: LegionCapabilityReport.optional()
15489
15848
  });
15490
15849
  var LegionControllerRegisterResponse = exports_external.strictObject({
15491
15850
  claimToken: nonEmptyString,
@@ -15618,10 +15977,10 @@ class LegionDaemonApiError extends Error {
15618
15977
  status;
15619
15978
  code;
15620
15979
  detail;
15621
- constructor(method, path3, status, code, detail) {
15622
- super(`${method} ${path3} failed with ${status}${code === undefined ? "" : ` ${code}`}: ${detail}`);
15980
+ constructor(method, path4, status, code, detail) {
15981
+ super(`${method} ${path4} failed with ${status}${code === undefined ? "" : ` ${code}`}: ${detail}`);
15623
15982
  this.method = method;
15624
- this.path = path3;
15983
+ this.path = path4;
15625
15984
  this.status = status;
15626
15985
  this.code = code;
15627
15986
  this.detail = detail;
@@ -15633,10 +15992,10 @@ class LegionDaemonContractError extends Error {
15633
15992
  method;
15634
15993
  path;
15635
15994
  status;
15636
- constructor(method, path3, status, problem) {
15637
- super(`${method} ${path3} answered ${status} with a body the daemon API contract does not describe: ${problem}`);
15995
+ constructor(method, path4, status, problem) {
15996
+ super(`${method} ${path4} answered ${status} with a body the daemon API contract does not describe: ${problem}`);
15638
15997
  this.method = method;
15639
- this.path = path3;
15998
+ this.path = path4;
15640
15999
  this.status = status;
15641
16000
  this.name = "LegionDaemonContractError";
15642
16001
  }
@@ -15657,8 +16016,8 @@ function parseStrictly(schema, body) {
15657
16016
  }
15658
16017
  function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15659
16018
  const endpoint = baseUrl.replace(/\/+$/, "");
15660
- const call = async (method, path3, body) => {
15661
- const response = await fetchFn(`${endpoint}${path3}`, body === undefined ? undefined : {
16019
+ const call = async (method, path4, body) => {
16020
+ const response = await fetchFn(`${endpoint}${path4}`, body === undefined ? undefined : {
15662
16021
  method,
15663
16022
  headers: { "Content-Type": "application/json" },
15664
16023
  body: JSON.stringify(body)
@@ -15669,23 +16028,23 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15669
16028
  const refusal = parseStrictly(LegionErrorResponse, text);
15670
16029
  if ("value" in refusal) {
15671
16030
  const code = "code" in refusal.value && typeof refusal.value.code === "string" ? refusal.value.code : undefined;
15672
- throw new LegionDaemonApiError(method, path3, response.status, code, refusal.value.error);
16031
+ throw new LegionDaemonApiError(method, path4, response.status, code, refusal.value.error);
15673
16032
  }
15674
- throw new LegionDaemonApiError(method, path3, response.status, undefined, `${JSON.stringify(text)} is not the daemon's refusal shape (${refusal.problem})`);
16033
+ throw new LegionDaemonApiError(method, path4, response.status, undefined, `${JSON.stringify(text)} is not the daemon's refusal shape (${refusal.problem})`);
15675
16034
  };
15676
- const read = async (method, path3, schema, body) => {
15677
- const { status, text } = await call(method, path3, body);
16035
+ const read = async (method, path4, schema, body) => {
16036
+ const { status, text } = await call(method, path4, body);
15678
16037
  const parsed = parseStrictly(schema, text);
15679
16038
  if ("problem" in parsed) {
15680
- throw new LegionDaemonContractError(method, path3, status, parsed.problem);
16039
+ throw new LegionDaemonContractError(method, path4, status, parsed.problem);
15681
16040
  }
15682
16041
  return parsed.value;
15683
16042
  };
15684
- const post = (path3, request, response, body) => read("POST", path3, response, request.parse(body));
15685
- const acknowledge = async (path3, body) => {
15686
- const { status, text } = await call("POST", path3, body);
16043
+ const post = (path4, request, response, body) => read("POST", path4, response, request.parse(body));
16044
+ const acknowledge = async (path4, body) => {
16045
+ const { status, text } = await call("POST", path4, body);
15687
16046
  if (status !== 204 || text !== "") {
15688
- throw new LegionDaemonContractError("POST", path3, status, "the route answers 204 with no body");
16047
+ throw new LegionDaemonContractError("POST", path4, status, "the route answers 204 with no body");
15689
16048
  }
15690
16049
  };
15691
16050
  return {
@@ -15712,16 +16071,16 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15712
16071
  // src/jj-attribution.ts
15713
16072
  import { execFile as execFile2 } from "child_process";
15714
16073
  import { mkdir, rename, writeFile } from "fs/promises";
15715
- import path3 from "path";
16074
+ import path4 from "path";
15716
16075
  import { promisify as promisify2 } from "util";
15717
16076
  var UUID = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
15718
16077
  var TOP_LEVEL_TRANSCRIPT = new RegExp(`^\\d{4}-\\d{2}-\\d{2}T[\\d-]+Z_(${UUID})\\.jsonl$`);
15719
16078
  var ATTRIBUTION_OVERLAY = /^omp-attribution-.*\.toml$/;
15720
16079
  function sessionIdFromTranscript(sessionFile) {
15721
- return TOP_LEVEL_TRANSCRIPT.exec(path3.basename(sessionFile))?.[1];
16080
+ return TOP_LEVEL_TRANSCRIPT.exec(path4.basename(sessionFile))?.[1];
15722
16081
  }
15723
16082
  function withoutOverlays(chain) {
15724
- return chain.split(":").filter((entry) => entry && !ATTRIBUTION_OVERLAY.test(path3.basename(entry))).join(":");
16083
+ return chain.split(":").filter((entry) => entry && !ATTRIBUTION_OVERLAY.test(path4.basename(entry))).join(":");
15725
16084
  }
15726
16085
  async function userConfigChain() {
15727
16086
  try {
@@ -15737,7 +16096,7 @@ async function exportJjSessionAttribution(sessionFile, stateDir, env = process.e
15737
16096
  const id = sessionIdFromTranscript(sessionFile);
15738
16097
  if (!id)
15739
16098
  return;
15740
- const overlay = path3.join(stateDir, `omp-attribution-${id}.toml`);
16099
+ const overlay = path4.join(stateDir, `omp-attribution-${id}.toml`);
15741
16100
  if (env.JJ_CONFIG?.split(":").includes(overlay))
15742
16101
  return;
15743
16102
  const base = (env.JJ_CONFIG ? withoutOverlays(env.JJ_CONFIG) : "") || await resolveBase();
@@ -15808,12 +16167,10 @@ function createClaimSession(deps) {
15808
16167
  agentId,
15809
16168
  pluginContract: package_default.legion.daemonApiVersion
15810
16169
  }).catch((error48) => exitOnRegistrationRefusal(error48, deps.exitProcess));
15811
- const ready = {
15812
- claimToken: claim.claimToken,
15813
- sessionId: sessionID,
15814
- secret: claim.secret,
15815
- generation: claim.generation
15816
- };
16170
+ const measuring = Promise.resolve().then(() => deps.measureCapabilities(context, { issue: claim.issue, promptAgents: claim.promptAgents })).catch((error48) => {
16171
+ console.error(`[legion] capability measurement failed; ready carries no report: ${messageFor(error48)}`);
16172
+ return;
16173
+ });
15817
16174
  try {
15818
16175
  const stateDir = requiredEnvironment(process.env, "LEGION_STATE_DIR");
15819
16176
  await exportJjSessionAttribution(sessionFile, stateDir);
@@ -15825,6 +16182,14 @@ function createClaimSession(deps) {
15825
16182
  secret: claim.secret
15826
16183
  };
15827
16184
  await claimEnvoyRole(sessionID, claim.claimToken, context);
16185
+ const capabilities = await measuring;
16186
+ const ready = {
16187
+ claimToken: claim.claimToken,
16188
+ sessionId: sessionID,
16189
+ secret: claim.secret,
16190
+ generation: claim.generation,
16191
+ ...capabilities === undefined ? {} : { capabilities }
16192
+ };
15828
16193
  await callReadyWithRetry("claims/ready", () => daemon.ready(ready));
15829
16194
  onEnvoyRoleRegained(async (role, reason) => {
15830
16195
  if (role !== claim.claimToken)
@@ -16406,13 +16771,17 @@ var exitProcess = (code) => process.exit(code);
16406
16771
  function setLegionBootstrapExitForTests(hook) {
16407
16772
  exitProcess = hook;
16408
16773
  }
16774
+ var capabilityHost = ompCapabilityHost;
16775
+ function setLegionCapabilityHostForTests(host2) {
16776
+ capabilityHost = host2;
16777
+ }
16409
16778
  async function persistedTranscript(context) {
16410
16779
  await context.sessionManager.ensureOnDisk();
16411
16780
  const sessionFile = context.sessionManager.getSessionFile();
16412
16781
  if (!sessionFile?.endsWith(".jsonl")) {
16413
16782
  throw new Error("Legion session must have a persisted transcript");
16414
16783
  }
16415
- const agentId = path4.basename(sessionFile, ".jsonl");
16784
+ const agentId = path5.basename(sessionFile, ".jsonl");
16416
16785
  if (!agentId)
16417
16786
  throw new Error("Legion session transcript has no agent id");
16418
16787
  return { sessionFile, agentId };
@@ -16462,6 +16831,14 @@ function legionExtension(pi) {
16462
16831
  const claimSession = createClaimSession({
16463
16832
  daemon: roleDaemon,
16464
16833
  persistedTranscript,
16834
+ measureCapabilities: (context, input) => measureCapabilities(capabilityHost(), {
16835
+ cwd: context.cwd,
16836
+ sessionId: context.sessionManager.getSessionId(),
16837
+ issue: input.issue,
16838
+ promptAgents: input.promptAgents,
16839
+ activeTools: pi.getActiveTools(),
16840
+ agentsExposed: pi.agents !== undefined
16841
+ }),
16465
16842
  exitProcess: (code) => exitProcess(code)
16466
16843
  });
16467
16844
  const controllerSession = createControllerSession({
@@ -16620,6 +16997,7 @@ function legionExtension(pi) {
16620
16997
  });
16621
16998
  }
16622
16999
  export {
17000
+ setLegionCapabilityHostForTests,
16623
17001
  setLegionBootstrapExitForTests,
16624
17002
  legionExtension as default
16625
17003
  };
@@ -193,12 +193,17 @@ unsent:
193
193
  `controller_wake_interval_seconds` (an hour by default), whatever the slots. An earlier walk
194
194
  that found nothing, a day with no event, and [a tree waiting on a root
195
195
  claim](#trees-waiting-on-a-root-claim-go-daemon) all get a turn from it. Its payload's
196
- `openCapabilities`, when present, names the deployment capabilities (the secrets broker, model
197
- fallback, every role's CPU and memory limits) the deployment's own configuration leaves open with
198
- no decision recorded. `read_state` lists every row under `capabilities`, each open one
199
- with its `detail` and the `configLine` to write into `legion.yaml`
200
- (`capabilities.decided.<name>: "<reason>"`); name them in the day's report. A gap is the
201
- operator's to close or to decide, never yours, and it never stops the walk.
196
+ `openCapabilities`, when present, names the open capability rows: the deployment capabilities
197
+ (the secrets broker, model fallback, every role's CPU and memory limits) the deployment's own
198
+ configuration leaves open with no decision recorded, and the live capabilities (`subagents`,
199
+ `web-search`, `mcp`, `repository-extensions`, `dispatch-envoy-tools`, `github`) a running
200
+ session's own check at its start failed. `read_state` lists every row under `capabilities`, each
201
+ open one with its `detail`: on a deployment row, with the `configLine` to write into
202
+ `legion.yaml` (`capabilities.decided.<name>: "<reason>"`); on a live row, naming each failing
203
+ session (its pod or pane, its role, its issue) and the fact its check found, with no
204
+ `configLine`, since nothing in `legion.yaml` decides a live row. Name them in the day's report.
205
+ A gap is the operator's to close or to decide — a live one the operator's or the issue's
206
+ architect's to fix in the session's environment — never yours, and it never stops the walk.
202
207
 
203
208
  **Scope first.** The scope the deployment instructions state decides which issues are candidates
204
209
  at all, before anything below. When they say you hand Legion no issue yourself, or that Legion
@@ -350,9 +355,12 @@ every pull request by its URL.
350
355
  when it made one: what it took, and how many candidates each row of the table skipped, so a
351
356
  reader can see why a free slot stays empty.
352
357
  - **Open capabilities.** Each row of `capabilities` in `read_state` whose `status` is
353
- `open` (the `tick` payload's `openCapabilities`): its name, its `detail`, and its `configLine`,
354
- the `legion.yaml` line that records a decision. The operator closes or decides it; you only
355
- report it, and a report with none says nothing of them.
358
+ `open` (the `tick` payload's `openCapabilities`): its name, its `detail`, and, on a deployment
359
+ row, its `configLine`, the `legion.yaml` line that records a decision. A live row a running
360
+ session failed has no `configLine`: its `detail` names the session (pod or pane), the role, the
361
+ issue and the fact, and nothing in `legion.yaml` decides it. The operator closes or decides a
362
+ deployment row, and the operator or the issue's architect fixes a live row's session
363
+ environment; you only report either, and a report with none says nothing of them.
356
364
 
357
365
  A day with nothing finished says so in one sentence. When the lists do not fit, keep the counts
358
366
  and the highest-priority issues.
@@ -394,7 +402,7 @@ priority first, then board rank ([Keeping the slots full](#keeping-the-slots-ful
394
402
  | New issue created in the Dispatch project, status `triage`: `triage on <KEY>` (payload `{kind: "triage"}`) on the controller topic, for an unrecorded root carrying the `legion` label only ("Issues handed to Legion" above); the start procedure above catches one sent while no controller ran | issue key + triage context (incl. pre-existing children) | Triage: `set_status` to `todo` to admit, or to `backlog`/`icebox` to park |
395
403
  | `slot-free on <KEY>` from the Go daemon (payload `{kind: "slot-free"}`) | the root whose slot the daemon released with no waiting root to take it | Verify a free slot in `read_state`, then fill it ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
396
404
  | `todo on <KEY>` from the Go daemon (payload `{kind: "todo"}`) | an issue not handed to Legion that changed while in `todo` and a slot stood free, sent half a minute later | Verify a free slot, then walk the whole `todo` list ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
397
- | `tick on <PROJECT>` from the Go daemon (payload `{kind: "tick", openCapabilities?: [<name>, …]}`) | the project key; the daemon's periodic wake, whatever the slots; `openCapabilities` names the deployment capabilities with no decision, each with its detail and `capabilities.decided.<name>` line under `capabilities` in `read_state` | Recheck the trees waiting on a claim, then walk if a slot is free — a gap never stops the walk; post the day's report if this is the day's first turn, naming each open capability in it (the operator closes or decides it) |
405
+ | `tick on <PROJECT>` from the Go daemon (payload `{kind: "tick", openCapabilities?: [<name>, …]}`) | the project key; the daemon's periodic wake, whatever the slots; `openCapabilities` names the deployment capabilities with no decision, each with its detail and `capabilities.decided.<name>` line under `capabilities` in `read_state`, and the live capabilities a running session's check failed, each with the detail naming the session (pod or pane), role, issue and fact, and no `configLine` | Recheck the trees waiting on a claim, then walk if a slot is free — a gap never stops the walk; post the day's report if this is the day's first turn, naming each open capability in it (the operator closes or decides a deployment row; the operator or the issue's architect fixes a live row's session; you take no other action) |
398
406
  | Architect escalation (controller-actionable only: re-file a child as a root issue, capacity, cross-tree conflicts) | request + context | Judge and act; the owning architect writes an issue-design decision as a decision block and opens `dispatch ask` only for a human to-do |
399
407
  | Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
400
408
  | `held on <KEY>` from the Go daemon (payload `{kind: "held", phase, role?, reason?}`) | the held issue, the phase it left, and the role whose claim failed, or `reason: "escalated"` | Verify the hold in `read_state` (the issue's phase is `held`). Without `reason`, a phase worker's launches or prompts ran out and the tree's architect decides retry or escalate: no action. With `reason: "escalated"` (on the record, `issues.<KEY>.holdReason` is `escalated`), the architect sent it to you: handle it as an architect escalation below. Parking the tree is `set_status` of the root to `backlog`; setting the root back to `todo` later re-admits it as a new generation, which starts again from its architect |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "8.15.0",
3
+ "version": "8.16.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "legion": {
15
- "daemonApiVersion": 20
15
+ "daemonApiVersion": 21
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",