@jmanuelcorral/openteam 0.9.3 → 0.10.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.es.md CHANGED
@@ -819,7 +819,7 @@ Consulta [docs/compatibility.md](docs/compatibility.md). Resumen verificado el `
819
819
 
820
820
  | Superficie | Versión / contrato |
821
821
  | --- | --- |
822
- | openteam | `0.9.3`; versión actual del paquete |
822
+ | openteam | `0.10.0`; versión actual del paquete |
823
823
  | `@opencode-ai/plugin` | `1.18.19` |
824
824
  | `@opencode-ai/sdk` | `1.18.19` |
825
825
  | Hook de routing | `chat.message`; no `chat.params` para cambiar modelo |
package/README.md CHANGED
@@ -700,7 +700,7 @@ See [docs/compatibility.md](docs/compatibility.md). Verified summary as of `2026
700
700
 
701
701
  | Surface | Version / contract |
702
702
  | --- | --- |
703
- | openteam | `0.9.3`; current package version |
703
+ | openteam | `0.10.0`; current package version |
704
704
  | `@opencode-ai/plugin` | `1.18.19` |
705
705
  | `@opencode-ai/sdk` | `1.18.19` |
706
706
  | Routing hook | `chat.message`; not `chat.params` for model changes |
package/dist/cli.js CHANGED
@@ -101,7 +101,8 @@ var AgentRoleProfileSchema = z.object({
101
101
  preferredFrontierModels: z.array(z.union([modelSelectionSchema, z.literal("auto")])),
102
102
  localFirst: z.boolean(),
103
103
  requiresLocalRuntime: z.boolean().optional(),
104
- localRuntimes: z.array(z.enum(["ollama", "lmstudio", "foundry-local", "lemonade"])).optional()
104
+ localRuntimes: z.array(z.enum(["ollama", "lmstudio", "foundry-local", "lemonade"])).optional(),
105
+ localModels: z.partialRecord(z.enum(["ollama", "lmstudio", "foundry-local", "lemonade"]), z.string().min(1)).optional()
105
106
  }).strict();
106
107
  var TEAM_ROLES = {
107
108
  architect: {
@@ -392,6 +393,9 @@ var OrchestratorRolesSchema = z2.record(z2.string().min(1), z2.unknown()).defaul
392
393
  if (parsed.data.localRuntimes !== undefined) {
393
394
  profile.localRuntimes = parsed.data.localRuntimes;
394
395
  }
396
+ if (parsed.data.localModels !== undefined) {
397
+ profile.localModels = parsed.data.localModels;
398
+ }
395
399
  parsedRoles[roleID] = profile;
396
400
  }
397
401
  return parsedRoles;
@@ -403,6 +407,7 @@ var defaultFrontierModel = {
403
407
  providerID: "anthropic",
404
408
  modelID: "claude-sonnet-4-5"
405
409
  };
410
+ var DEFAULT_LOCAL_MAX_CONCURRENCY = 4;
406
411
  var LocalRuntimeSchema = z2.object({
407
412
  id: z2.enum(["ollama", "lmstudio", "foundry-local", "lemonade"]),
408
413
  enabled: z2.boolean().default(true),
@@ -473,13 +478,23 @@ var OpenTeamConfigObjectSchema = z2.object({
473
478
  localDefault: ModelRefSchema.nullable().default(null),
474
479
  trivialPromptMaxChars: z2.number().int().positive().default(280),
475
480
  frontierPromptMinChars: z2.number().int().positive().default(2000),
476
- frontierOnly: z2.boolean().default(false)
477
- }).strict().default({
481
+ frontierOnly: z2.boolean().default(false),
482
+ localOnly: z2.boolean().default(false)
483
+ }).strict().superRefine((router, ctx) => {
484
+ if (router.frontierOnly && router.localOnly) {
485
+ ctx.addIssue({
486
+ code: "custom",
487
+ path: ["localOnly"],
488
+ message: "router.localOnly and router.frontierOnly cannot both be true"
489
+ });
490
+ }
491
+ }).default({
478
492
  mode: "balanced",
479
493
  localDefault: null,
480
494
  trivialPromptMaxChars: 280,
481
495
  frontierPromptMinChars: 2000,
482
- frontierOnly: false
496
+ frontierOnly: false,
497
+ localOnly: false
483
498
  }),
484
499
  local: z2.object({
485
500
  runtimes: z2.array(LocalRuntimeSchema).default([])
@@ -863,6 +878,16 @@ function chooseModel(input) {
863
878
  if (override === "alwaysFrontier") {
864
879
  return finalizeDecision(input, tier, frontier, budgetAction, decision(frontier.model, "frontier", ["explicit-always-frontier"], []));
865
880
  }
881
+ if (input.config.router.localOnly) {
882
+ if (local === null) {
883
+ return finalizeDecision(input, tier, frontier, budgetAction, blockedDecision(frontier.model, ["router-local-only", "forced-local-none-configured"], "forced-local-none-configured"));
884
+ }
885
+ return finalizeDecision(input, tier, frontier, budgetAction, resolveLocalPrimary(input, local, frontier.model, {
886
+ baseRationale: ["router-local-only"],
887
+ allowFrontier: false,
888
+ noLocalRationale: "forced-local-unavailable"
889
+ }));
890
+ }
866
891
  if (input.config.router.frontierOnly) {
867
892
  return finalizeDecision(input, tier, frontier, budgetAction, decision(frontier.model, "frontier", ["router-frontier-only"], []));
868
893
  }
@@ -1485,6 +1510,10 @@ function configuredRuntime(runtimeId, config) {
1485
1510
  function runtimeIdOfModel(model, config) {
1486
1511
  return config.local.runtimes.find((runtime) => sameModel(runtime.defaultModel, model))?.id;
1487
1512
  }
1513
+ function roleLocalModel(role, runtimeId, fallback) {
1514
+ const pinned = role.localModels?.[runtimeId];
1515
+ return pinned === undefined ? fallback : { providerID: runtimeId, modelID: pinned };
1516
+ }
1488
1517
  function selectDispatchRuntime(role, decision2, reachable, config) {
1489
1518
  if (decision2.routeKind !== "local") {
1490
1519
  return { runtimeId: undefined, decision: decision2 };
@@ -1499,15 +1528,25 @@ function selectDispatchRuntime(role, decision2, reachable, config) {
1499
1528
  if (runtime === undefined) {
1500
1529
  continue;
1501
1530
  }
1502
- const model = runtime.defaultModel;
1531
+ const model = roleLocalModel(role, runtimeId, runtime.defaultModel);
1503
1532
  return {
1504
1533
  runtimeId,
1505
1534
  decision: { ...decision2, selected: model }
1506
1535
  };
1507
1536
  }
1508
1537
  }
1538
+ const resolvedRuntimeId = runtimeIdOfModel(decision2.selected, config);
1539
+ if (resolvedRuntimeId !== undefined) {
1540
+ const pinned = roleLocalModel(role, resolvedRuntimeId, decision2.selected);
1541
+ if (pinned !== decision2.selected) {
1542
+ return {
1543
+ runtimeId: resolvedRuntimeId,
1544
+ decision: { ...decision2, selected: pinned }
1545
+ };
1546
+ }
1547
+ }
1509
1548
  return {
1510
- runtimeId: runtimeIdOfModel(decision2.selected, config),
1549
+ runtimeId: resolvedRuntimeId,
1511
1550
  decision: decision2
1512
1551
  };
1513
1552
  }
@@ -2877,7 +2916,8 @@ var KNOWN_CONFIG_KEYS_BY_PATH = new Map([
2877
2916
  "localDefault",
2878
2917
  "trivialPromptMaxChars",
2879
2918
  "frontierPromptMinChars",
2880
- "frontierOnly"
2919
+ "frontierOnly",
2920
+ "localOnly"
2881
2921
  ]
2882
2922
  ],
2883
2923
  ["router.localDefault", MODEL_REF_KEYS],
@@ -8660,16 +8700,21 @@ function buildOrchestratorAgent(frontier, options = {}) {
8660
8700
  " `routing-cost` (plus the guaranteed `orchestrator`, `guardian`,",
8661
8701
  " `scribe`, `ralph`). The roleID space is open: use free-form project",
8662
8702
  " roleIDs only when no curated role fits.",
8663
- `5. **Register the cast** in \`${OPENTEAM_ROSTER_PATH}\` so the names persist`,
8664
- " across sessions. This file goes **outside** `.opencode/agent/` (otherwise",
8665
- " opencode would load it as a phantom agent). Always reuse the same cast;",
8666
- " do not re-cast without reason.",
8703
+ `5. **Register the cast** by calling the \`openteam-register-cast\` tool`,
8704
+ " with `{ universe, entries: [{ roleID, agentName }] }`. The tool validates",
8705
+ " the payload, re-asserts any missing guaranteed role, and persists the",
8706
+ ` roster to \`${OPENTEAM_ROSTER_PATH}\` outside \`.opencode/agent/\`,`,
8707
+ " which would otherwise load it as a phantom agent. Never write that file",
8708
+ " by hand: an invalid draft is rejected naming the offending field and",
8709
+ " nothing is written. Always reuse the same cast; do not re-cast without",
8710
+ " reason.",
8667
8711
  "",
8668
8712
  "## Roster file format",
8669
8713
  "",
8670
- "Write the roster as Markdown with exactly one fenced `json` block matching",
8671
- "`{ universe, entries: [{ roleID, agentName }] }`. Do not write a table.",
8672
- "Minimal parseable example:",
8714
+ "`openteam-register-cast` persists the roster as Markdown with exactly one",
8715
+ "fenced `json` block matching `{ universe, entries: [{ roleID, agentName }] }`,",
8716
+ "and preserves any prose already in the document. Never write a table: the",
8717
+ "parser reads only the fenced block. Minimal parseable example:",
8673
8718
  "",
8674
8719
  "# openteam roster",
8675
8720
  "",
@@ -8727,8 +8772,8 @@ function buildOrchestratorAgent(frontier, options = {}) {
8727
8772
  " frontier only for architecture, security or ambiguous debugging:",
8728
8773
  " openteam already routes cheapest-capable automatically.",
8729
8774
  " - A focused, actionable role prompt.",
8730
- `4. Register the cast in \`${OPENTEAM_ROSTER_PATH}\` and **hand out the`,
8731
- " tasks** to the newly created team with the `task` tool.",
8775
+ "4. Register the cast with the `openteam-register-cast` tool and **hand out",
8776
+ " the tasks** to the newly created team with the `task` tool.",
8732
8777
  "",
8733
8778
  "## Standard team roles",
8734
8779
  "",
@@ -10315,6 +10360,41 @@ var migrateMessages = {
10315
10360
  manualWorktreeEntry: (label, from, to) => ` · ${label}: ${from} → ${to}`,
10316
10361
  success: " ✓ machine-local runtime output relocated under .opencode/openteam-local/."
10317
10362
  };
10363
+ var rolesInitMessages = {
10364
+ header: "openteam roles init:",
10365
+ title: "openteam roles init",
10366
+ noRoster: (path) => ` ✗ no roster at ${path}.`,
10367
+ noRosterRemedy: " Cast the team first: run `openteam setup`, then ask the orchestrator to register the cast.",
10368
+ unparseableRoster: (path, error) => ` ✗ ${path} is present but unparseable: ${error}`,
10369
+ unparseableRosterRemedy: " Run `openteam doctor` for the remedy, then re-run this command.",
10370
+ allProfiled: (count) => ` ✓ ${count} roster role(s) checked; no unprofiled roles — every role resolves a routing profile.`,
10371
+ whyTitle: "Why you are being asked",
10372
+ why: (count) => [
10373
+ `${count} roster role(s) resolve no routing profile.`,
10374
+ "Until a profile exists they are frontier-eligible: their work can escalate",
10375
+ "to a frontier model. A 'local only' note in the roster prose does not",
10376
+ "prevent this — prose outside the JSON fence is never read."
10377
+ ].join(`
10378
+ `),
10379
+ policyQuestion: (roleID, agentName) => `Routing policy for "${roleID}" (${agentName})`,
10380
+ localOnlyLabel: (roleID, agentName) => `Local only — ${roleID} (${agentName}) never leaves this machine`,
10381
+ localOnlyHint: "sets requiresLocalRuntime; the role fails closed when no local runtime is reachable",
10382
+ frontierOkLabel: "Local first, frontier allowed",
10383
+ frontierOkHint: "records today's implicit behaviour: cheapest-capable frontier when the task warrants it",
10384
+ skipLabel: "Skip for now",
10385
+ skipHint: "leaves the role unprofiled, so it stays frontier-eligible and doctor keeps reporting it",
10386
+ runtimeQuestion: (roleID) => `Preferred local runtime(s) for "${roleID}" (in failover order)`,
10387
+ runtimeHint: "machine-local — written to the git-ignored overlay",
10388
+ cancelled: (reason) => ` ✗ cancelled (${reason}); nothing was written.`,
10389
+ invalidResult: (reason) => ` ✗ refusing to write: the resulting config is invalid — ${reason}`,
10390
+ nothingWritten: (skipped) => ` · skipped ${skipped}; nothing written.`,
10391
+ nothingToWriteOutro: "Nothing to write.",
10392
+ pinnedLocal: (roleIDs, path) => ` ✓ pinned local-only: ${roleIDs} → ${path} (tracked, travels with the roster)`,
10393
+ frontierAllowed: (roleIDs, path) => ` ✓ frontier allowed: ${roleIDs} → ${path} (model choice stays "auto": cheapest-capable per task)`,
10394
+ runtimeBinding: (path) => ` ✓ runtime binding: ${path} (git-ignored, machine-local)`,
10395
+ skippedSummary: (roleIDs) => ` · skipped: ${roleIDs} — still frontier-eligible; re-run this command to revisit.`,
10396
+ outro: "Role policy updated."
10397
+ };
10318
10398
 
10319
10399
  // src/commands/baseline.ts
10320
10400
  function formatRef(ref) {
@@ -10458,10 +10538,23 @@ function renderConsoleStatus(console_) {
10458
10538
  }
10459
10539
 
10460
10540
  // src/commands/doctor.ts
10461
- function runtimeLine(snapshot) {
10541
+ function runtimeLine(snapshot, runtime) {
10462
10542
  const mark = snapshot.reachable ? "✓" : "✗";
10463
10543
  const detail = snapshot.reachable ? `${snapshot.models.length} model(s)` : snapshot.error ?? "unreachable";
10464
- return ` ${mark} ${snapshot.id.padEnd(14)} ${snapshot.baseURL || "(no baseURL)"} — ${detail}`;
10544
+ const declared = runtime?.maxConcurrency;
10545
+ const slots = declared === undefined ? ` · ${DEFAULT_LOCAL_MAX_CONCURRENCY} slot(s) (default)` : ` · ${declared} slot(s) (declared)`;
10546
+ return ` ${mark} ${snapshot.id.padEnd(14)} ${snapshot.baseURL || "(no baseURL)"} — ${detail}${slots}`;
10547
+ }
10548
+ function isNetworkRuntime(baseURL) {
10549
+ if (baseURL.length === 0) {
10550
+ return false;
10551
+ }
10552
+ try {
10553
+ const host = new URL(baseURL).hostname.toLowerCase();
10554
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "[::1]" && !host.endsWith(".localhost");
10555
+ } catch {
10556
+ return false;
10557
+ }
10465
10558
  }
10466
10559
  function failureReasonText(reason, searchedPaths) {
10467
10560
  switch (reason) {
@@ -10617,7 +10710,8 @@ function rosterHealthSection(audit, loadError, rosterRoleCount) {
10617
10710
  if (unprofiled.length > 0) {
10618
10711
  const roleIDs = unprofiled.map((entry) => entry.roleID).join(", ");
10619
10712
  lines.push(` · unprofiled roleID(s): ${roleIDs}`);
10620
- lines.push(" remedy: if intentional project roles, no action is required; for curated routing, change roleID to a TEAM_ROLES key or add a profile under orchestrator.roles in .opencode/openteam.json.");
10713
+ lines.push(" routing: no profile means frontier-eligible work for these roles can escalate to a frontier model. A 'local only' note in the roster prose does NOT prevent this: prose outside the JSON fence is never read.");
10714
+ lines.push(' remedy: run `openteam roles init` to compose a profile interactively, or add one by hand under orchestrator.roles in .opencode/openteam.json with "requiresLocalRuntime": true to pin the role local; for curated routing, change roleID to a TEAM_ROLES key instead.');
10621
10715
  }
10622
10716
  return lines;
10623
10717
  }
@@ -10652,9 +10746,17 @@ function renderDoctor(input) {
10652
10746
  lines.push(" (no runtime enabled)");
10653
10747
  } else {
10654
10748
  for (const snapshot of input.snapshots) {
10655
- lines.push(runtimeLine(snapshot));
10749
+ lines.push(runtimeLine(snapshot, enabledRuntimes.find((r) => r.id === snapshot.id)));
10656
10750
  }
10657
10751
  }
10752
+ for (const snapshot of input.snapshots) {
10753
+ if (!isNetworkRuntime(snapshot.baseURL)) {
10754
+ continue;
10755
+ }
10756
+ const runtime = enabledRuntimes.find((r) => r.id === snapshot.id);
10757
+ const cap = runtime?.maxConcurrency ?? DEFAULT_LOCAL_MAX_CONCURRENCY;
10758
+ lines.push(` ⚠ ${snapshot.id} is reached over the network: its ${cap}-slot cap is enforced per openteam process, so`, " two processes at once (an opencode session plus 'openteam console') can together exceed it.");
10759
+ }
10658
10760
  lines.push(` telemetry: ${input.telemetryPath} — ${input.telemetryRecords} record(s)`);
10659
10761
  if (input.diagnostics !== undefined) {
10660
10762
  lines.push(...diagnosticsSection(input.diagnostics));
@@ -11157,7 +11259,8 @@ function buildOpenTeamConfig(answers) {
11157
11259
  const runtime = {
11158
11260
  id: choice.id,
11159
11261
  enabled: choice.enabled,
11160
- defaultModel: { providerID: choice.id, modelID: choice.defaultModelID }
11262
+ defaultModel: { providerID: choice.id, modelID: choice.defaultModelID },
11263
+ maxConcurrency: choice.maxConcurrency ?? DEFAULT_LOCAL_MAX_CONCURRENCY
11161
11264
  };
11162
11265
  if (choice.baseURL !== undefined) {
11163
11266
  runtime.baseURL = choice.baseURL;
@@ -11168,6 +11271,7 @@ function buildOpenTeamConfig(answers) {
11168
11271
  });
11169
11272
  const primaryLocal = firstEnabled(answers.runtimes);
11170
11273
  const localDefault2 = primaryLocal !== undefined ? { providerID: primaryLocal.id, modelID: primaryLocal.defaultModelID } : null;
11274
+ const localOnly = answers.localOnly === true && primaryLocal !== undefined;
11171
11275
  return OpenTeamConfigSchema.parse({
11172
11276
  baseline: {
11173
11277
  mode: "auto",
@@ -11177,7 +11281,8 @@ function buildOpenTeamConfig(answers) {
11177
11281
  router: {
11178
11282
  mode: answers.routerMode,
11179
11283
  localDefault: localDefault2,
11180
- frontierOnly: primaryLocal === undefined
11284
+ frontierOnly: primaryLocal === undefined,
11285
+ localOnly
11181
11286
  },
11182
11287
  local: { runtimes },
11183
11288
  privacyMode: answers.privacyMode,
@@ -11207,15 +11312,17 @@ function buildOpencodeConfig(answers) {
11207
11312
  models
11208
11313
  };
11209
11314
  }
11315
+ const primaryLocal = firstEnabled(answers.runtimes);
11316
+ const localOnly = answers.localOnly === true && primaryLocal !== undefined;
11317
+ const mainModel = localOnly && primaryLocal !== undefined ? `${primaryLocal.id}/${primaryLocal.defaultModelID}` : `${answers.frontier.providerID}/${answers.frontier.modelID}`;
11210
11318
  const config = {
11211
11319
  $schema: "https://opencode.ai/config.json",
11212
11320
  plugin: [OPENTEAM_PLUGIN_SPEC],
11213
- model: `${answers.frontier.providerID}/${answers.frontier.modelID}`
11321
+ model: mainModel
11214
11322
  };
11215
11323
  if (Object.keys(provider).length > 0) {
11216
11324
  config.provider = provider;
11217
11325
  }
11218
- const primaryLocal = firstEnabled(answers.runtimes);
11219
11326
  if (primaryLocal !== undefined) {
11220
11327
  config.small_model = `${primaryLocal.id}/${primaryLocal.defaultModelID}`;
11221
11328
  }
@@ -11276,6 +11383,13 @@ async function browseFrontier(prompt, profiles) {
11276
11383
  const modelID = await prompt.select(opts);
11277
11384
  return { providerID, modelID };
11278
11385
  }
11386
+ function cheapestFrontier(profiles) {
11387
+ const first = sortFrontierByCost(profiles)[0];
11388
+ if (first === undefined) {
11389
+ throw new Error("no frontier profile available for the baseline");
11390
+ }
11391
+ return { providerID: first.ref.providerID, modelID: first.ref.modelID };
11392
+ }
11279
11393
  async function chooseFrontier(prompt, profiles) {
11280
11394
  const sorted = sortFrontierByCost(profiles);
11281
11395
  const first = sorted[0];
@@ -11462,6 +11576,10 @@ async function runSetup(deps) {
11462
11576
  runtimes.push(await resolveEnabledRuntime(deps, meta, info));
11463
11577
  }
11464
11578
  const hasLocalEnabled = enabled.size > 0;
11579
+ const localOnly = hasLocalEnabled ? await prompt.confirm({
11580
+ message: "Run without frontier models? (local runtimes only; openteam never escalates)",
11581
+ initial: false
11582
+ }) : false;
11465
11583
  let frontierProfiles;
11466
11584
  try {
11467
11585
  const loaded = await deps.loadFrontierProfiles();
@@ -11469,7 +11587,18 @@ async function runSetup(deps) {
11469
11587
  } catch {
11470
11588
  frontierProfiles = [...CURATED_FRONTIER_PROFILES];
11471
11589
  }
11472
- const frontier = await chooseFrontier(prompt, frontierProfiles);
11590
+ const frontier = localOnly ? cheapestFrontier(frontierProfiles) : await chooseFrontier(prompt, frontierProfiles);
11591
+ if (localOnly) {
11592
+ prompt.note([
11593
+ "openteam will never escalate to a frontier model.",
11594
+ "opencode's main model is pointed at your local runtime too, so the",
11595
+ "session itself does not reach a frontier provider either.",
11596
+ "",
11597
+ `A baseline is still recorded (${frontier.providerID}/${frontier.modelID})`,
11598
+ "but stays unused. Set router.localOnly to false to re-enable it."
11599
+ ].join(`
11600
+ `), "Local-only mode");
11601
+ }
11473
11602
  const routerMode = await prompt.select({
11474
11603
  message: "Routing mode",
11475
11604
  choices: [
@@ -11514,7 +11643,8 @@ async function runSetup(deps) {
11514
11643
  frontier,
11515
11644
  routerMode,
11516
11645
  privacyMode,
11517
- yolo
11646
+ yolo,
11647
+ localOnly
11518
11648
  };
11519
11649
  const openTeamConfig = buildOpenTeamConfig(answers);
11520
11650
  const opencodeConfig = buildOpencodeConfig(answers);
@@ -11582,14 +11712,14 @@ async function runSetup(deps) {
11582
11712
  return `${runtime.id} (${runtime.defaultModelID})${remote}`;
11583
11713
  }).join(", ");
11584
11714
  prompt.note([
11585
- `Baseline frontier: ${frontier.providerID}/${frontier.modelID}`,
11715
+ `Baseline frontier: ${localOnly ? `${frontier.providerID}/${frontier.modelID} (recorded but unused — local-only)` : `${frontier.providerID}/${frontier.modelID}`}`,
11586
11716
  `Local runtimes: ${enabledSummary || "none (frontier-only)"}`,
11587
11717
  `YOLO mode: ${yolo ? "enabled (auto-approves permissions)" : "disabled"}`,
11588
11718
  "Web Console: launched separately from the CLI with 'openteam console' (multi-session, loopback)",
11589
11719
  `Wrote: ${opencodePath}, ${DEFAULT_CONFIG_PATH}, ${ORCHESTRATOR_AGENT_PATH}, ${AGENT_DIR}/*.md (${roleAgentFiles.length} standard subagents), ${OPENTEAM_COMMAND_DIR}/*.md (${slashCommands.length} commands); ensured ${OPENCODE_GITIGNORE_PATH}`,
11590
11720
  "",
11591
11721
  "Next steps:",
11592
- ` 1. Authenticate the frontier provider: opencode auth login`,
11722
+ localOnly ? " 1. No frontier auth needed; openteam runs entirely on your local runtimes" : ` 1. Authenticate the frontier provider: opencode auth login`,
11593
11723
  hasLocalEnabled ? " 2. Make sure your local runtime (local or on the network) is reachable; openteam routes local-first automatically" : " 2. Open opencode in this repo; openteam will use frontier models only",
11594
11724
  " 3. Press Tab and pick the 'openteam' agent, or type / and pick an /openteam… command"
11595
11725
  ].join(`
@@ -12280,6 +12410,7 @@ var HELP = [
12280
12410
  " openteam local off Use frontier models only (no local runtime)",
12281
12411
  " openteam local on Re-enable local-first routing",
12282
12412
  " openteam doctor Diagnose runtimes and config",
12413
+ " openteam roles init Compose per-role routing policy for unprofiled roster roles",
12283
12414
  " openteam migrate Relocate pre-P15b artifacts to their split homes (idempotent)",
12284
12415
  " openteam agents List agents and each one's LLM (local/frontier)",
12285
12416
  " openteam console Launch the multi-session web Console (Ctrl+C to stop; --open opens the browser)",
@@ -13211,6 +13342,178 @@ ${HELP}`
13211
13342
  }
13212
13343
  }
13213
13344
 
13345
+ // src/commands/rolesInit.ts
13346
+ var ROLES_INIT_LOCAL_ONLY = "local-only";
13347
+ var ROLES_INIT_FRONTIER_OK = "frontier-ok";
13348
+ var ROLES_INIT_SKIP = "skip";
13349
+ function isJsonRecord2(value) {
13350
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13351
+ }
13352
+ function serialize(value) {
13353
+ return `${JSON.stringify(value, null, 2)}
13354
+ `;
13355
+ }
13356
+ async function readJsonFile(deps, path3) {
13357
+ const content = await deps.readFile(path3);
13358
+ if (content === undefined)
13359
+ return {};
13360
+ const raw = parseConfigFileJson(path3, content);
13361
+ return isJsonRecord2(raw) ? raw : {};
13362
+ }
13363
+ function existingRoles(config) {
13364
+ const orchestrator = config.orchestrator;
13365
+ if (!isJsonRecord2(orchestrator))
13366
+ return {};
13367
+ const roles = orchestrator.roles;
13368
+ return isJsonRecord2(roles) ? roles : {};
13369
+ }
13370
+ function withRole(config, roleID, profile) {
13371
+ const orchestrator = isJsonRecord2(config.orchestrator) ? config.orchestrator : {};
13372
+ const roles = isJsonRecord2(orchestrator.roles) ? orchestrator.roles : {};
13373
+ return {
13374
+ ...config,
13375
+ orchestrator: {
13376
+ ...orchestrator,
13377
+ roles: { ...roles, [roleID]: profile }
13378
+ }
13379
+ };
13380
+ }
13381
+ function localOnlyProfile(roleID) {
13382
+ const base = synthesiseFallbackProfile(roleID);
13383
+ return {
13384
+ ...base,
13385
+ preferredFrontierModels: [],
13386
+ localFirst: true,
13387
+ requiresLocalRuntime: true
13388
+ };
13389
+ }
13390
+ function frontierEligibleProfile(roleID) {
13391
+ return { ...synthesiseFallbackProfile(roleID) };
13392
+ }
13393
+ function policyChoices(roleID, agentName) {
13394
+ return [
13395
+ {
13396
+ value: ROLES_INIT_LOCAL_ONLY,
13397
+ label: rolesInitMessages.localOnlyLabel(roleID, agentName),
13398
+ hint: rolesInitMessages.localOnlyHint
13399
+ },
13400
+ {
13401
+ value: ROLES_INIT_FRONTIER_OK,
13402
+ label: rolesInitMessages.frontierOkLabel,
13403
+ hint: rolesInitMessages.frontierOkHint
13404
+ },
13405
+ {
13406
+ value: ROLES_INIT_SKIP,
13407
+ label: rolesInitMessages.skipLabel,
13408
+ hint: rolesInitMessages.skipHint
13409
+ }
13410
+ ];
13411
+ }
13412
+ async function runRolesInit(deps) {
13413
+ const lines = [rolesInitMessages.header];
13414
+ const rosterContent = await deps.readFile(OPENTEAM_ROSTER_PATH);
13415
+ if (rosterContent === undefined) {
13416
+ lines.push(rolesInitMessages.noRoster(OPENTEAM_ROSTER_PATH), rolesInitMessages.noRosterRemedy);
13417
+ return { stdout: lines.join(`
13418
+ `), exitCode: 1 };
13419
+ }
13420
+ const parsed = parseRosterResult(rosterContent);
13421
+ if (!parsed.ok) {
13422
+ lines.push(rolesInitMessages.unparseableRoster(OPENTEAM_ROSTER_PATH, parsed.error.message), rolesInitMessages.unparseableRosterRemedy);
13423
+ return { stdout: lines.join(`
13424
+ `), exitCode: 1 };
13425
+ }
13426
+ const baseConfig = await readJsonFile(deps, DEFAULT_CONFIG_PATH);
13427
+ const configuredRoles = existingRoles(baseConfig);
13428
+ const unprofiled = parsed.roster.entries.filter((entry) => getRoleProfile(entry.roleID, configuredRoles) === undefined && !isKnownNonWorkerRole(entry.roleID));
13429
+ if (unprofiled.length === 0) {
13430
+ lines.push(rolesInitMessages.allProfiled(parsed.roster.entries.length));
13431
+ return { stdout: lines.join(`
13432
+ `), exitCode: 0 };
13433
+ }
13434
+ deps.prompt.intro(rolesInitMessages.title);
13435
+ deps.prompt.note(rolesInitMessages.why(unprofiled.length), rolesInitMessages.whyTitle);
13436
+ const runtimes = (await deps.detectRuntimes?.())?.filter((runtime) => runtime.available);
13437
+ let nextConfig = baseConfig;
13438
+ let nextOverlay;
13439
+ const pinned = [];
13440
+ const relaxed = [];
13441
+ const skipped = [];
13442
+ try {
13443
+ for (const entry of unprofiled) {
13444
+ const choice = await deps.prompt.select({
13445
+ message: rolesInitMessages.policyQuestion(entry.roleID, entry.agentName),
13446
+ choices: policyChoices(entry.roleID, entry.agentName),
13447
+ initial: ROLES_INIT_LOCAL_ONLY
13448
+ });
13449
+ if (choice === ROLES_INIT_SKIP) {
13450
+ skipped.push(entry.roleID);
13451
+ continue;
13452
+ }
13453
+ if (choice === ROLES_INIT_FRONTIER_OK) {
13454
+ nextConfig = withRole(nextConfig, entry.roleID, frontierEligibleProfile(entry.roleID));
13455
+ relaxed.push(entry.roleID);
13456
+ continue;
13457
+ }
13458
+ nextConfig = withRole(nextConfig, entry.roleID, localOnlyProfile(entry.roleID));
13459
+ pinned.push(entry.roleID);
13460
+ if (runtimes !== undefined && runtimes.length > 0) {
13461
+ const selected = await deps.prompt.multiselect({
13462
+ message: rolesInitMessages.runtimeQuestion(entry.roleID),
13463
+ choices: runtimes.map((runtime) => ({
13464
+ value: runtime.id,
13465
+ label: runtime.id,
13466
+ hint: rolesInitMessages.runtimeHint
13467
+ }))
13468
+ });
13469
+ if (selected.length > 0) {
13470
+ nextOverlay = withRole(nextOverlay ?? {}, entry.roleID, {
13471
+ localRuntimes: selected
13472
+ });
13473
+ }
13474
+ }
13475
+ }
13476
+ } catch (error) {
13477
+ const message = error instanceof Error ? error.message : String(error);
13478
+ lines.push(rolesInitMessages.cancelled(message));
13479
+ return { stdout: lines.join(`
13480
+ `), exitCode: 1 };
13481
+ }
13482
+ if (pinned.length === 0 && relaxed.length === 0) {
13483
+ lines.push(rolesInitMessages.nothingWritten(skipped.join(", ")));
13484
+ deps.prompt.outro(rolesInitMessages.nothingToWriteOutro);
13485
+ return { stdout: lines.join(`
13486
+ `), exitCode: 0 };
13487
+ }
13488
+ const merged = mergeConfigInputs(nextConfig, nextOverlay);
13489
+ try {
13490
+ loadOpenTeamConfig(merged);
13491
+ } catch (error) {
13492
+ lines.push(rolesInitMessages.invalidResult(error instanceof Error ? error.message : String(error)));
13493
+ return { stdout: lines.join(`
13494
+ `), exitCode: 1 };
13495
+ }
13496
+ await deps.writeFile(DEFAULT_CONFIG_PATH, serialize(nextConfig));
13497
+ if (nextOverlay !== undefined) {
13498
+ await deps.writeFile(DEFAULT_LOCAL_OVERLAY_PATH, serialize(nextOverlay));
13499
+ }
13500
+ if (pinned.length > 0) {
13501
+ lines.push(rolesInitMessages.pinnedLocal(pinned.join(", "), DEFAULT_CONFIG_PATH));
13502
+ }
13503
+ if (relaxed.length > 0) {
13504
+ lines.push(rolesInitMessages.frontierAllowed(relaxed.join(", "), DEFAULT_CONFIG_PATH));
13505
+ }
13506
+ if (nextOverlay !== undefined) {
13507
+ lines.push(rolesInitMessages.runtimeBinding(DEFAULT_LOCAL_OVERLAY_PATH));
13508
+ }
13509
+ if (skipped.length > 0) {
13510
+ lines.push(rolesInitMessages.skippedSummary(skipped.join(", ")));
13511
+ }
13512
+ deps.prompt.outro(rolesInitMessages.outro);
13513
+ return { stdout: lines.join(`
13514
+ `), exitCode: 0 };
13515
+ }
13516
+
13214
13517
  // src/config/legacyMigration.ts
13215
13518
  async function relocateNode(from, to, port, files) {
13216
13519
  if (!await port.exists(from)) {
@@ -14656,16 +14959,18 @@ async function* linesFromReadable(readable) {
14656
14959
  }
14657
14960
 
14658
14961
  // src/orchestrator/rosterPersistence.ts
14659
- function serializeRoster(roster) {
14962
+ function rosterFence(roster) {
14660
14963
  const data = { universe: roster.universe, entries: roster.entries };
14661
- const json = JSON.stringify(data, null, 2);
14964
+ return `\`\`\`json
14965
+ ${JSON.stringify(data, null, 2)}
14966
+ \`\`\``;
14967
+ }
14968
+ function serializeRoster(roster) {
14662
14969
  return `# openteam roster
14663
14970
 
14664
14971
  Universe: ${roster.universe}
14665
14972
 
14666
- \`\`\`json
14667
- ${json}
14668
- \`\`\`
14973
+ ${rosterFence(roster)}
14669
14974
  `;
14670
14975
  }
14671
14976
  async function persistRoster(storage, roster, path4 = OPENTEAM_ROSTER_PATH) {
@@ -14958,7 +15263,7 @@ async function buildMemoryRuntimeFromConfig(config, deps, options = {}) {
14958
15263
  // package.json
14959
15264
  var package_default = {
14960
15265
  name: "@jmanuelcorral/openteam",
14961
- version: "0.9.3",
15266
+ version: "0.10.0",
14962
15267
  packageManager: "bun@1.3.14",
14963
15268
  description: "Cost-aware, local-first routing plugin for opencode with cheapest-capable frontier fallback and multi-agent orchestration.",
14964
15269
  license: "MIT",
@@ -15375,6 +15680,20 @@ function createSetupDeps() {
15375
15680
  }
15376
15681
  };
15377
15682
  }
15683
+ function createRolesInitDeps() {
15684
+ const detect = createDetect(nodeExec2);
15685
+ return {
15686
+ prompt: clackPrompter,
15687
+ readFile: (path4) => storage.read(path4),
15688
+ writeFile: async (path4, contents) => {
15689
+ await storage.write(path4, contents);
15690
+ },
15691
+ detectRuntimes: async () => (await detect()).map((runtime) => ({
15692
+ id: runtime.id,
15693
+ available: runtime.reachable
15694
+ }))
15695
+ };
15696
+ }
15378
15697
  async function main() {
15379
15698
  const argv = process.argv.slice(2);
15380
15699
  const configSelection = parseConfigSelection(argv);
@@ -15382,6 +15701,15 @@ async function main() {
15382
15701
  const result2 = await runSetup(createSetupDeps());
15383
15702
  if (result2.stdout.length > 0) {
15384
15703
  process.stdout.write(`${result2.stdout}
15704
+ `);
15705
+ }
15706
+ process.exitCode = result2.exitCode;
15707
+ return;
15708
+ }
15709
+ if (argv[0] === "roles" && argv[1] === "init") {
15710
+ const result2 = await runRolesInit(createRolesInitDeps());
15711
+ if (result2.stdout.length > 0) {
15712
+ process.stdout.write(`${result2.stdout}
15385
15713
  `);
15386
15714
  }
15387
15715
  process.exitCode = result2.exitCode;