@jmanuelcorral/openteam 0.9.4 → 0.11.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/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],
@@ -9899,6 +9939,123 @@ function isGateApplicable(mode) {
9899
9939
  return mode === "active";
9900
9940
  }
9901
9941
 
9942
+ // src/messages/commands.ts
9943
+ var clearCacheMessages = {
9944
+ header: "openteam clear-cache — frozen plugin cache entries:",
9945
+ columns: {
9946
+ specDir: "spec dir",
9947
+ pinned: "spec-pinned",
9948
+ installed: "installed",
9949
+ mtime: "mtime"
9950
+ },
9951
+ reparseSkipSuffix: " [SKIP — reparse point]",
9952
+ deletedLabel: "deleted.",
9953
+ lockedLabel: (message) => `[LOCKED] ${message}`,
9954
+ pathOutsideWarning: (specDir, absolutePath) => ` ⚠ ${specDir}: path outside cacheRoot (${absolutePath}), skipped.`,
9955
+ processed: (count) => `${count} entry(ies) processed.`,
9956
+ found: (count) => `${count} entry(ies) found. Use --delete to remove them.`
9957
+ };
9958
+ var baselineMessages = {
9959
+ effectiveAuto: "cheapest-capable (auto)",
9960
+ pinnedSuffix: (ref) => `${ref} (pinned)`,
9961
+ summary: (params) => [
9962
+ "openteam baseline:",
9963
+ ` mode: ${params.mode}`,
9964
+ ` pinned: ${params.pinned}`,
9965
+ ` hardDefault: ${params.hardDefault}`,
9966
+ ` effective: ${params.effective}`
9967
+ ],
9968
+ invalidModel: (input) => `Invalid model "${input}". Use the provider/model format, e.g. anthropic/claude-sonnet-4-5.`,
9969
+ pinnedTo: (ref) => `Baseline pinned to ${ref} (pinned mode).`,
9970
+ autoMode: "Baseline set to auto mode (cheapest-capable)."
9971
+ };
9972
+ var localMessages = {
9973
+ help: {
9974
+ status: " openteam local status Show the routing mode (local-first / local-only / frontier-only)",
9975
+ off: " openteam local off Use frontier models only (no local runtime)",
9976
+ only: " openteam local only Use local models only (never frontier)",
9977
+ on: " openteam local on Re-enable local-first routing"
9978
+ },
9979
+ noRuntimes: "none",
9980
+ modeFrontierOnly: "frontier-only (frontierOnly)",
9981
+ modeLocalOnly: "local-only (localOnly)",
9982
+ modeLocalFirst: "local-first",
9983
+ summary: (params) => [
9984
+ "openteam local:",
9985
+ ` mode: ${params.mode}`,
9986
+ ` frontierOnly: ${params.frontierOnly}`,
9987
+ ` localOnly: ${params.localOnly}`,
9988
+ ` privacy: ${params.privacy}`,
9989
+ ` runtimes: ${params.runtimes}`
9990
+ ],
9991
+ frontierOnlyNoChange: "No change: already in frontier-only mode.",
9992
+ privacyAdjustedNote: " Privacy adjusted to 'consentBeforeFrontier' (forceLocalOnSensitive requires a local runtime).",
9993
+ frontierOnlyEnabled: "Frontier-only mode enabled (frontierOnly=true, localOnly=false).",
9994
+ localOnlyNoRuntimes: "Cannot enable local-only mode: no enabled local runtime is configured. Run 'openteam setup' or enable a local runtime in .opencode/openteam.json first.",
9995
+ localOnlyNoChange: "No change: already in local-only mode.",
9996
+ localOnlyEnabled: "Local-only mode enabled (localOnly=true, frontierOnly=false).",
9997
+ localFirstNoChange: "No change: local-first routing is already active.",
9998
+ localFirstReEnabled: "Local-first routing re-enabled (frontierOnly=false, localOnly=false). Make sure a local runtime is reachable.",
9999
+ unknownSubcommand: (subcommand, help) => `Unknown local subcommand: ${subcommand}
10000
+
10001
+ ${help}`
10002
+ };
10003
+ var frontierCatalogMessages = {
10004
+ free: "free",
10005
+ costPerMillion: (inputUSD, outputUSD) => `$${inputUSD}/$${outputUSD} per 1M`
10006
+ };
10007
+ var migrateMessages = {
10008
+ header: "openteam migrate:",
10009
+ nothingToMigrate: " nothing to migrate — the P15b layout is already in place.",
10010
+ counts: (params) => [
10011
+ ` moved: ${params.moved} file(s)`,
10012
+ ` deduped: ${params.deduped} file(s) (destination already identical)`,
10013
+ ` conflicts: ${params.conflicts} file(s)`
10014
+ ],
10015
+ relocatedHeader: " relocated:",
10016
+ relocatedEntry: (from, to, deduped) => ` · ${from} → ${to}${deduped ? " (deduped)" : ""}`,
10017
+ conflictsHeader: " ⚠ left in place (destination exists with different content — no data lost):",
10018
+ conflictEntry: (from, to) => ` · ${from} → ${to}`,
10019
+ manualWorktreesHeader: " ⚠ legacy git worktree(s) need a manual move (run `git worktree move`):",
10020
+ manualWorktreeEntry: (label, from, to) => ` · ${label}: ${from} → ${to}`,
10021
+ success: " ✓ machine-local runtime output relocated under .opencode/openteam-local/."
10022
+ };
10023
+ var rolesInitMessages = {
10024
+ header: "openteam roles init:",
10025
+ title: "openteam roles init",
10026
+ noRoster: (path) => ` ✗ no roster at ${path}.`,
10027
+ noRosterRemedy: " Cast the team first: run `openteam setup`, then ask the orchestrator to register the cast.",
10028
+ unparseableRoster: (path, error) => ` ✗ ${path} is present but unparseable: ${error}`,
10029
+ unparseableRosterRemedy: " Run `openteam doctor` for the remedy, then re-run this command.",
10030
+ allProfiled: (count) => ` ✓ ${count} roster role(s) checked; no unprofiled roles — every role resolves a routing profile.`,
10031
+ whyTitle: "Why you are being asked",
10032
+ why: (count) => [
10033
+ `${count} roster role(s) resolve no routing profile.`,
10034
+ "Until a profile exists they are frontier-eligible: their work can escalate",
10035
+ "to a frontier model. A 'local only' note in the roster prose does not",
10036
+ "prevent this — prose outside the JSON fence is never read."
10037
+ ].join(`
10038
+ `),
10039
+ policyQuestion: (roleID, agentName) => `Routing policy for "${roleID}" (${agentName})`,
10040
+ localOnlyLabel: (roleID, agentName) => `Local only — ${roleID} (${agentName}) never leaves this machine`,
10041
+ localOnlyHint: "sets requiresLocalRuntime; the role fails closed when no local runtime is reachable",
10042
+ frontierOkLabel: "Local first, frontier allowed",
10043
+ frontierOkHint: "records today's implicit behaviour: cheapest-capable frontier when the task warrants it",
10044
+ skipLabel: "Skip for now",
10045
+ skipHint: "leaves the role unprofiled, so it stays frontier-eligible and doctor keeps reporting it",
10046
+ runtimeQuestion: (roleID) => `Preferred local runtime(s) for "${roleID}" (in failover order)`,
10047
+ runtimeHint: "machine-local — written to the git-ignored overlay",
10048
+ cancelled: (reason) => ` ✗ cancelled (${reason}); nothing was written.`,
10049
+ invalidResult: (reason) => ` ✗ refusing to write: the resulting config is invalid — ${reason}`,
10050
+ nothingWritten: (skipped) => ` · skipped ${skipped}; nothing written.`,
10051
+ nothingToWriteOutro: "Nothing to write.",
10052
+ pinnedLocal: (roleIDs, path) => ` ✓ pinned local-only: ${roleIDs} → ${path} (tracked, travels with the roster)`,
10053
+ frontierAllowed: (roleIDs, path) => ` ✓ frontier allowed: ${roleIDs} → ${path} (model choice stays "auto": cheapest-capable per task)`,
10054
+ runtimeBinding: (path) => ` ✓ runtime binding: ${path} (git-ignored, machine-local)`,
10055
+ skippedSummary: (roleIDs) => ` · skipped: ${roleIDs} — still frontier-eligible; re-run this command to revisit.`,
10056
+ outro: "Role policy updated."
10057
+ };
10058
+
9902
10059
  // src/orchestrator/ralphLoop.ts
9903
10060
  var RALPH_ROLE_ID = "ralph";
9904
10061
  var DEFAULT_RALPH_MAX_ITERATIONS = 10;
@@ -10253,74 +10410,6 @@ function describeOtelBackendForDoctor(config, env) {
10253
10410
  };
10254
10411
  }
10255
10412
 
10256
- // src/messages/commands.ts
10257
- var clearCacheMessages = {
10258
- header: "openteam clear-cache — frozen plugin cache entries:",
10259
- columns: {
10260
- specDir: "spec dir",
10261
- pinned: "spec-pinned",
10262
- installed: "installed",
10263
- mtime: "mtime"
10264
- },
10265
- reparseSkipSuffix: " [SKIP — reparse point]",
10266
- deletedLabel: "deleted.",
10267
- lockedLabel: (message) => `[LOCKED] ${message}`,
10268
- pathOutsideWarning: (specDir, absolutePath) => ` ⚠ ${specDir}: path outside cacheRoot (${absolutePath}), skipped.`,
10269
- processed: (count) => `${count} entry(ies) processed.`,
10270
- found: (count) => `${count} entry(ies) found. Use --delete to remove them.`
10271
- };
10272
- var baselineMessages = {
10273
- effectiveAuto: "cheapest-capable (auto)",
10274
- pinnedSuffix: (ref) => `${ref} (pinned)`,
10275
- summary: (params) => [
10276
- "openteam baseline:",
10277
- ` mode: ${params.mode}`,
10278
- ` pinned: ${params.pinned}`,
10279
- ` hardDefault: ${params.hardDefault}`,
10280
- ` effective: ${params.effective}`
10281
- ],
10282
- invalidModel: (input) => `Invalid model "${input}". Use the provider/model format, e.g. anthropic/claude-sonnet-4-5.`,
10283
- pinnedTo: (ref) => `Baseline pinned to ${ref} (pinned mode).`,
10284
- autoMode: "Baseline set to auto mode (cheapest-capable)."
10285
- };
10286
- var localMessages = {
10287
- noRuntimes: "none",
10288
- modeFrontierOnly: "frontier-only (frontierOnly)",
10289
- modeLocalFirst: "local-first",
10290
- summary: (params) => [
10291
- "openteam local:",
10292
- ` mode: ${params.mode}`,
10293
- ` frontierOnly: ${params.frontierOnly}`,
10294
- ` privacy: ${params.privacy}`,
10295
- ` runtimes: ${params.runtimes}`
10296
- ],
10297
- frontierOnlyNoChange: "No change: already in frontier-only mode.",
10298
- privacyAdjustedNote: " Privacy adjusted to 'consentBeforeFrontier' (forceLocalOnSensitive requires a local runtime).",
10299
- frontierOnlyEnabled: "Frontier-only mode enabled (frontierOnly=true).",
10300
- localFirstNoChange: "No change: local-first routing is already active.",
10301
- localFirstReEnabled: "Local-first routing re-enabled (frontierOnly=false). Make sure a local runtime is reachable."
10302
- };
10303
- var frontierCatalogMessages = {
10304
- free: "free",
10305
- costPerMillion: (inputUSD, outputUSD) => `$${inputUSD}/$${outputUSD} per 1M`
10306
- };
10307
- var migrateMessages = {
10308
- header: "openteam migrate:",
10309
- nothingToMigrate: " nothing to migrate — the P15b layout is already in place.",
10310
- counts: (params) => [
10311
- ` moved: ${params.moved} file(s)`,
10312
- ` deduped: ${params.deduped} file(s) (destination already identical)`,
10313
- ` conflicts: ${params.conflicts} file(s)`
10314
- ],
10315
- relocatedHeader: " relocated:",
10316
- relocatedEntry: (from, to, deduped) => ` · ${from} → ${to}${deduped ? " (deduped)" : ""}`,
10317
- conflictsHeader: " ⚠ left in place (destination exists with different content — no data lost):",
10318
- conflictEntry: (from, to) => ` · ${from} → ${to}`,
10319
- manualWorktreesHeader: " ⚠ legacy git worktree(s) need a manual move (run `git worktree move`):",
10320
- manualWorktreeEntry: (label, from, to) => ` · ${label}: ${from} → ${to}`,
10321
- success: " ✓ machine-local runtime output relocated under .opencode/openteam-local/."
10322
- };
10323
-
10324
10413
  // src/commands/baseline.ts
10325
10414
  function formatRef(ref) {
10326
10415
  return ref === null ? "—" : `${ref.providerID}/${ref.modelID}`;
@@ -10463,10 +10552,23 @@ function renderConsoleStatus(console_) {
10463
10552
  }
10464
10553
 
10465
10554
  // src/commands/doctor.ts
10466
- function runtimeLine(snapshot) {
10555
+ function runtimeLine(snapshot, runtime) {
10467
10556
  const mark = snapshot.reachable ? "✓" : "✗";
10468
10557
  const detail = snapshot.reachable ? `${snapshot.models.length} model(s)` : snapshot.error ?? "unreachable";
10469
- return ` ${mark} ${snapshot.id.padEnd(14)} ${snapshot.baseURL || "(no baseURL)"} — ${detail}`;
10558
+ const declared = runtime?.maxConcurrency;
10559
+ const slots = declared === undefined ? ` · ${DEFAULT_LOCAL_MAX_CONCURRENCY} slot(s) (default)` : ` · ${declared} slot(s) (declared)`;
10560
+ return ` ${mark} ${snapshot.id.padEnd(14)} ${snapshot.baseURL || "(no baseURL)"} — ${detail}${slots}`;
10561
+ }
10562
+ function isNetworkRuntime(baseURL) {
10563
+ if (baseURL.length === 0) {
10564
+ return false;
10565
+ }
10566
+ try {
10567
+ const host = new URL(baseURL).hostname.toLowerCase();
10568
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "[::1]" && !host.endsWith(".localhost");
10569
+ } catch {
10570
+ return false;
10571
+ }
10470
10572
  }
10471
10573
  function failureReasonText(reason, searchedPaths) {
10472
10574
  switch (reason) {
@@ -10622,7 +10724,8 @@ function rosterHealthSection(audit, loadError, rosterRoleCount) {
10622
10724
  if (unprofiled.length > 0) {
10623
10725
  const roleIDs = unprofiled.map((entry) => entry.roleID).join(", ");
10624
10726
  lines.push(` · unprofiled roleID(s): ${roleIDs}`);
10625
- 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.");
10727
+ 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.");
10728
+ 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.');
10626
10729
  }
10627
10730
  return lines;
10628
10731
  }
@@ -10657,8 +10760,16 @@ function renderDoctor(input) {
10657
10760
  lines.push(" (no runtime enabled)");
10658
10761
  } else {
10659
10762
  for (const snapshot of input.snapshots) {
10660
- lines.push(runtimeLine(snapshot));
10763
+ lines.push(runtimeLine(snapshot, enabledRuntimes.find((r) => r.id === snapshot.id)));
10764
+ }
10765
+ }
10766
+ for (const snapshot of input.snapshots) {
10767
+ if (!isNetworkRuntime(snapshot.baseURL)) {
10768
+ continue;
10661
10769
  }
10770
+ const runtime = enabledRuntimes.find((r) => r.id === snapshot.id);
10771
+ const cap = runtime?.maxConcurrency ?? DEFAULT_LOCAL_MAX_CONCURRENCY;
10772
+ 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.");
10662
10773
  }
10663
10774
  lines.push(` telemetry: ${input.telemetryPath} — ${input.telemetryRecords} record(s)`);
10664
10775
  if (input.diagnostics !== undefined) {
@@ -10800,6 +10911,9 @@ function renderWorktreeReconciliation(report) {
10800
10911
  }
10801
10912
 
10802
10913
  // src/commands/local.ts
10914
+ function hasEnabledLocalRuntime(config) {
10915
+ return config.local.runtimes.some((runtime) => runtime.enabled);
10916
+ }
10803
10917
  function enabledRuntimesSummary(config) {
10804
10918
  const enabled = config.local.runtimes.filter((runtime) => runtime.enabled);
10805
10919
  if (enabled.length === 0) {
@@ -10812,10 +10926,11 @@ function enabledRuntimesSummary(config) {
10812
10926
  }).join(", ");
10813
10927
  }
10814
10928
  function showLocal(config) {
10815
- const mode = config.router.frontierOnly ? localMessages.modeFrontierOnly : localMessages.modeLocalFirst;
10929
+ const mode = config.router.localOnly ? localMessages.modeLocalOnly : config.router.frontierOnly ? localMessages.modeFrontierOnly : localMessages.modeLocalFirst;
10816
10930
  const lines = localMessages.summary({
10817
10931
  mode,
10818
10932
  frontierOnly: config.router.frontierOnly,
10933
+ localOnly: config.router.localOnly,
10819
10934
  privacy: config.privacyMode,
10820
10935
  runtimes: enabledRuntimesSummary(config)
10821
10936
  });
@@ -10823,13 +10938,13 @@ function showLocal(config) {
10823
10938
  `) };
10824
10939
  }
10825
10940
  function setFrontierOnly(config) {
10826
- if (config.router.frontierOnly) {
10941
+ if (config.router.frontierOnly && !config.router.localOnly) {
10827
10942
  return { message: localMessages.frontierOnlyNoChange };
10828
10943
  }
10829
10944
  const flipsPrivacy = config.privacyMode === "forceLocalOnSensitive";
10830
10945
  const next = {
10831
10946
  ...config,
10832
- router: { ...config.router, frontierOnly: true },
10947
+ router: { ...config.router, frontierOnly: true, localOnly: false },
10833
10948
  privacyMode: flipsPrivacy ? "consentBeforeFrontier" : config.privacyMode
10834
10949
  };
10835
10950
  const privacyNote = flipsPrivacy ? localMessages.privacyAdjustedNote : "";
@@ -10838,13 +10953,29 @@ function setFrontierOnly(config) {
10838
10953
  message: `${localMessages.frontierOnlyEnabled}${privacyNote}`
10839
10954
  };
10840
10955
  }
10956
+ function setLocalOnly(config) {
10957
+ if (!hasEnabledLocalRuntime(config)) {
10958
+ return { message: localMessages.localOnlyNoRuntimes };
10959
+ }
10960
+ if (config.router.localOnly && !config.router.frontierOnly) {
10961
+ return { message: localMessages.localOnlyNoChange };
10962
+ }
10963
+ const next = {
10964
+ ...config,
10965
+ router: { ...config.router, frontierOnly: false, localOnly: true }
10966
+ };
10967
+ return {
10968
+ config: next,
10969
+ message: localMessages.localOnlyEnabled
10970
+ };
10971
+ }
10841
10972
  function setLocalFirst(config) {
10842
- if (!config.router.frontierOnly) {
10973
+ if (!config.router.frontierOnly && !config.router.localOnly) {
10843
10974
  return { message: localMessages.localFirstNoChange };
10844
10975
  }
10845
10976
  const next = {
10846
10977
  ...config,
10847
- router: { ...config.router, frontierOnly: false }
10978
+ router: { ...config.router, frontierOnly: false, localOnly: false }
10848
10979
  };
10849
10980
  return {
10850
10981
  config: next,
@@ -11162,7 +11293,8 @@ function buildOpenTeamConfig(answers) {
11162
11293
  const runtime = {
11163
11294
  id: choice.id,
11164
11295
  enabled: choice.enabled,
11165
- defaultModel: { providerID: choice.id, modelID: choice.defaultModelID }
11296
+ defaultModel: { providerID: choice.id, modelID: choice.defaultModelID },
11297
+ maxConcurrency: choice.maxConcurrency ?? DEFAULT_LOCAL_MAX_CONCURRENCY
11166
11298
  };
11167
11299
  if (choice.baseURL !== undefined) {
11168
11300
  runtime.baseURL = choice.baseURL;
@@ -11173,6 +11305,7 @@ function buildOpenTeamConfig(answers) {
11173
11305
  });
11174
11306
  const primaryLocal = firstEnabled(answers.runtimes);
11175
11307
  const localDefault2 = primaryLocal !== undefined ? { providerID: primaryLocal.id, modelID: primaryLocal.defaultModelID } : null;
11308
+ const localOnly = answers.localOnly === true && primaryLocal !== undefined;
11176
11309
  return OpenTeamConfigSchema.parse({
11177
11310
  baseline: {
11178
11311
  mode: "auto",
@@ -11182,7 +11315,8 @@ function buildOpenTeamConfig(answers) {
11182
11315
  router: {
11183
11316
  mode: answers.routerMode,
11184
11317
  localDefault: localDefault2,
11185
- frontierOnly: primaryLocal === undefined
11318
+ frontierOnly: primaryLocal === undefined,
11319
+ localOnly
11186
11320
  },
11187
11321
  local: { runtimes },
11188
11322
  privacyMode: answers.privacyMode,
@@ -11212,15 +11346,17 @@ function buildOpencodeConfig(answers) {
11212
11346
  models
11213
11347
  };
11214
11348
  }
11349
+ const primaryLocal = firstEnabled(answers.runtimes);
11350
+ const localOnly = answers.localOnly === true && primaryLocal !== undefined;
11351
+ const mainModel = localOnly && primaryLocal !== undefined ? `${primaryLocal.id}/${primaryLocal.defaultModelID}` : `${answers.frontier.providerID}/${answers.frontier.modelID}`;
11215
11352
  const config = {
11216
11353
  $schema: "https://opencode.ai/config.json",
11217
11354
  plugin: [OPENTEAM_PLUGIN_SPEC],
11218
- model: `${answers.frontier.providerID}/${answers.frontier.modelID}`
11355
+ model: mainModel
11219
11356
  };
11220
11357
  if (Object.keys(provider).length > 0) {
11221
11358
  config.provider = provider;
11222
11359
  }
11223
- const primaryLocal = firstEnabled(answers.runtimes);
11224
11360
  if (primaryLocal !== undefined) {
11225
11361
  config.small_model = `${primaryLocal.id}/${primaryLocal.defaultModelID}`;
11226
11362
  }
@@ -11281,6 +11417,13 @@ async function browseFrontier(prompt, profiles) {
11281
11417
  const modelID = await prompt.select(opts);
11282
11418
  return { providerID, modelID };
11283
11419
  }
11420
+ function cheapestFrontier(profiles) {
11421
+ const first = sortFrontierByCost(profiles)[0];
11422
+ if (first === undefined) {
11423
+ throw new Error("no frontier profile available for the baseline");
11424
+ }
11425
+ return { providerID: first.ref.providerID, modelID: first.ref.modelID };
11426
+ }
11284
11427
  async function chooseFrontier(prompt, profiles) {
11285
11428
  const sorted = sortFrontierByCost(profiles);
11286
11429
  const first = sorted[0];
@@ -11467,6 +11610,10 @@ async function runSetup(deps) {
11467
11610
  runtimes.push(await resolveEnabledRuntime(deps, meta, info));
11468
11611
  }
11469
11612
  const hasLocalEnabled = enabled.size > 0;
11613
+ const localOnly = hasLocalEnabled ? await prompt.confirm({
11614
+ message: "Run without frontier models? (local runtimes only; openteam never escalates)",
11615
+ initial: false
11616
+ }) : false;
11470
11617
  let frontierProfiles;
11471
11618
  try {
11472
11619
  const loaded = await deps.loadFrontierProfiles();
@@ -11474,7 +11621,18 @@ async function runSetup(deps) {
11474
11621
  } catch {
11475
11622
  frontierProfiles = [...CURATED_FRONTIER_PROFILES];
11476
11623
  }
11477
- const frontier = await chooseFrontier(prompt, frontierProfiles);
11624
+ const frontier = localOnly ? cheapestFrontier(frontierProfiles) : await chooseFrontier(prompt, frontierProfiles);
11625
+ if (localOnly) {
11626
+ prompt.note([
11627
+ "openteam will never escalate to a frontier model.",
11628
+ "opencode's main model is pointed at your local runtime too, so the",
11629
+ "session itself does not reach a frontier provider either.",
11630
+ "",
11631
+ `A baseline is still recorded (${frontier.providerID}/${frontier.modelID})`,
11632
+ "but stays unused. Set router.localOnly to false to re-enable it."
11633
+ ].join(`
11634
+ `), "Local-only mode");
11635
+ }
11478
11636
  const routerMode = await prompt.select({
11479
11637
  message: "Routing mode",
11480
11638
  choices: [
@@ -11519,7 +11677,8 @@ async function runSetup(deps) {
11519
11677
  frontier,
11520
11678
  routerMode,
11521
11679
  privacyMode,
11522
- yolo
11680
+ yolo,
11681
+ localOnly
11523
11682
  };
11524
11683
  const openTeamConfig = buildOpenTeamConfig(answers);
11525
11684
  const opencodeConfig = buildOpencodeConfig(answers);
@@ -11587,14 +11746,14 @@ async function runSetup(deps) {
11587
11746
  return `${runtime.id} (${runtime.defaultModelID})${remote}`;
11588
11747
  }).join(", ");
11589
11748
  prompt.note([
11590
- `Baseline frontier: ${frontier.providerID}/${frontier.modelID}`,
11749
+ `Baseline frontier: ${localOnly ? `${frontier.providerID}/${frontier.modelID} (recorded but unused — local-only)` : `${frontier.providerID}/${frontier.modelID}`}`,
11591
11750
  `Local runtimes: ${enabledSummary || "none (frontier-only)"}`,
11592
11751
  `YOLO mode: ${yolo ? "enabled (auto-approves permissions)" : "disabled"}`,
11593
11752
  "Web Console: launched separately from the CLI with 'openteam console' (multi-session, loopback)",
11594
11753
  `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}`,
11595
11754
  "",
11596
11755
  "Next steps:",
11597
- ` 1. Authenticate the frontier provider: opencode auth login`,
11756
+ localOnly ? " 1. No frontier auth needed; openteam runs entirely on your local runtimes" : ` 1. Authenticate the frontier provider: opencode auth login`,
11598
11757
  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",
11599
11758
  " 3. Press Tab and pick the 'openteam' agent, or type / and pick an /openteam… command"
11600
11759
  ].join(`
@@ -12281,10 +12440,12 @@ var HELP = [
12281
12440
  " openteam baseline show Show the effective baseline",
12282
12441
  " openteam baseline set <p/model> Pin the baseline (pinned mode)",
12283
12442
  " openteam baseline auto Cheapest-capable baseline (auto mode)",
12284
- " openteam local status Show the routing mode (local-first / frontier-only)",
12285
- " openteam local off Use frontier models only (no local runtime)",
12286
- " openteam local on Re-enable local-first routing",
12443
+ localMessages.help.status,
12444
+ localMessages.help.off,
12445
+ localMessages.help.only,
12446
+ localMessages.help.on,
12287
12447
  " openteam doctor Diagnose runtimes and config",
12448
+ " openteam roles init Compose per-role routing policy for unprofiled roster roles",
12288
12449
  " openteam migrate Relocate pre-P15b artifacts to their split homes (idempotent)",
12289
12450
  " openteam agents List agents and each one's LLM (local/frontier)",
12290
12451
  " openteam console Launch the multi-session web Console (Ctrl+C to stop; --open opens the browser)",
@@ -12453,23 +12614,40 @@ ${HELP}`
12453
12614
  }
12454
12615
  async function runLocal(positionals, deps, configPath, configResolution) {
12455
12616
  const sub = positionals[1] ?? "status";
12456
- if (sub !== "status" && sub !== "off" && sub !== "on") {
12617
+ if (sub !== "status" && sub !== "off" && sub !== "only" && sub !== "on") {
12457
12618
  return {
12458
12619
  exitCode: 1,
12459
- stdout: `Unknown local subcommand: ${sub}
12460
-
12461
- ${HELP}`
12620
+ stdout: localMessages.unknownSubcommand(sub, HELP)
12462
12621
  };
12463
12622
  }
12464
12623
  const config = await deps.loadConfig(configPath, configResolution);
12465
12624
  if (sub === "status") {
12466
12625
  return { exitCode: 0, stdout: showLocal(config).message };
12467
12626
  }
12468
- const outcome = sub === "off" ? setFrontierOnly(config) : setLocalFirst(config);
12627
+ let outcome;
12628
+ switch (sub) {
12629
+ case "off":
12630
+ outcome = setFrontierOnly(config);
12631
+ break;
12632
+ case "only":
12633
+ outcome = setLocalOnly(config);
12634
+ break;
12635
+ case "on":
12636
+ outcome = setLocalFirst(config);
12637
+ break;
12638
+ default:
12639
+ return {
12640
+ exitCode: 1,
12641
+ stdout: localMessages.unknownSubcommand(sub, HELP)
12642
+ };
12643
+ }
12469
12644
  if (outcome.config !== undefined) {
12470
12645
  await deps.saveConfig(outcome.config, configPath, configResolution);
12471
12646
  }
12472
- return { exitCode: 0, stdout: outcome.message };
12647
+ return {
12648
+ exitCode: sub === "only" && !hasEnabledLocalRuntime(config) ? 1 : 0,
12649
+ stdout: outcome.message
12650
+ };
12473
12651
  }
12474
12652
  async function runYolo(positionals, deps, opencodeConfigPaths) {
12475
12653
  const sub = positionals[1] ?? "status";
@@ -13216,6 +13394,178 @@ ${HELP}`
13216
13394
  }
13217
13395
  }
13218
13396
 
13397
+ // src/commands/rolesInit.ts
13398
+ var ROLES_INIT_LOCAL_ONLY = "local-only";
13399
+ var ROLES_INIT_FRONTIER_OK = "frontier-ok";
13400
+ var ROLES_INIT_SKIP = "skip";
13401
+ function isJsonRecord2(value) {
13402
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13403
+ }
13404
+ function serialize(value) {
13405
+ return `${JSON.stringify(value, null, 2)}
13406
+ `;
13407
+ }
13408
+ async function readJsonFile(deps, path3) {
13409
+ const content = await deps.readFile(path3);
13410
+ if (content === undefined)
13411
+ return {};
13412
+ const raw = parseConfigFileJson(path3, content);
13413
+ return isJsonRecord2(raw) ? raw : {};
13414
+ }
13415
+ function existingRoles(config) {
13416
+ const orchestrator = config.orchestrator;
13417
+ if (!isJsonRecord2(orchestrator))
13418
+ return {};
13419
+ const roles = orchestrator.roles;
13420
+ return isJsonRecord2(roles) ? roles : {};
13421
+ }
13422
+ function withRole(config, roleID, profile) {
13423
+ const orchestrator = isJsonRecord2(config.orchestrator) ? config.orchestrator : {};
13424
+ const roles = isJsonRecord2(orchestrator.roles) ? orchestrator.roles : {};
13425
+ return {
13426
+ ...config,
13427
+ orchestrator: {
13428
+ ...orchestrator,
13429
+ roles: { ...roles, [roleID]: profile }
13430
+ }
13431
+ };
13432
+ }
13433
+ function localOnlyProfile(roleID) {
13434
+ const base = synthesiseFallbackProfile(roleID);
13435
+ return {
13436
+ ...base,
13437
+ preferredFrontierModels: [],
13438
+ localFirst: true,
13439
+ requiresLocalRuntime: true
13440
+ };
13441
+ }
13442
+ function frontierEligibleProfile(roleID) {
13443
+ return { ...synthesiseFallbackProfile(roleID) };
13444
+ }
13445
+ function policyChoices(roleID, agentName) {
13446
+ return [
13447
+ {
13448
+ value: ROLES_INIT_LOCAL_ONLY,
13449
+ label: rolesInitMessages.localOnlyLabel(roleID, agentName),
13450
+ hint: rolesInitMessages.localOnlyHint
13451
+ },
13452
+ {
13453
+ value: ROLES_INIT_FRONTIER_OK,
13454
+ label: rolesInitMessages.frontierOkLabel,
13455
+ hint: rolesInitMessages.frontierOkHint
13456
+ },
13457
+ {
13458
+ value: ROLES_INIT_SKIP,
13459
+ label: rolesInitMessages.skipLabel,
13460
+ hint: rolesInitMessages.skipHint
13461
+ }
13462
+ ];
13463
+ }
13464
+ async function runRolesInit(deps) {
13465
+ const lines = [rolesInitMessages.header];
13466
+ const rosterContent = await deps.readFile(OPENTEAM_ROSTER_PATH);
13467
+ if (rosterContent === undefined) {
13468
+ lines.push(rolesInitMessages.noRoster(OPENTEAM_ROSTER_PATH), rolesInitMessages.noRosterRemedy);
13469
+ return { stdout: lines.join(`
13470
+ `), exitCode: 1 };
13471
+ }
13472
+ const parsed = parseRosterResult(rosterContent);
13473
+ if (!parsed.ok) {
13474
+ lines.push(rolesInitMessages.unparseableRoster(OPENTEAM_ROSTER_PATH, parsed.error.message), rolesInitMessages.unparseableRosterRemedy);
13475
+ return { stdout: lines.join(`
13476
+ `), exitCode: 1 };
13477
+ }
13478
+ const baseConfig = await readJsonFile(deps, DEFAULT_CONFIG_PATH);
13479
+ const configuredRoles = existingRoles(baseConfig);
13480
+ const unprofiled = parsed.roster.entries.filter((entry) => getRoleProfile(entry.roleID, configuredRoles) === undefined && !isKnownNonWorkerRole(entry.roleID));
13481
+ if (unprofiled.length === 0) {
13482
+ lines.push(rolesInitMessages.allProfiled(parsed.roster.entries.length));
13483
+ return { stdout: lines.join(`
13484
+ `), exitCode: 0 };
13485
+ }
13486
+ deps.prompt.intro(rolesInitMessages.title);
13487
+ deps.prompt.note(rolesInitMessages.why(unprofiled.length), rolesInitMessages.whyTitle);
13488
+ const runtimes = (await deps.detectRuntimes?.())?.filter((runtime) => runtime.available);
13489
+ let nextConfig = baseConfig;
13490
+ let nextOverlay;
13491
+ const pinned = [];
13492
+ const relaxed = [];
13493
+ const skipped = [];
13494
+ try {
13495
+ for (const entry of unprofiled) {
13496
+ const choice = await deps.prompt.select({
13497
+ message: rolesInitMessages.policyQuestion(entry.roleID, entry.agentName),
13498
+ choices: policyChoices(entry.roleID, entry.agentName),
13499
+ initial: ROLES_INIT_LOCAL_ONLY
13500
+ });
13501
+ if (choice === ROLES_INIT_SKIP) {
13502
+ skipped.push(entry.roleID);
13503
+ continue;
13504
+ }
13505
+ if (choice === ROLES_INIT_FRONTIER_OK) {
13506
+ nextConfig = withRole(nextConfig, entry.roleID, frontierEligibleProfile(entry.roleID));
13507
+ relaxed.push(entry.roleID);
13508
+ continue;
13509
+ }
13510
+ nextConfig = withRole(nextConfig, entry.roleID, localOnlyProfile(entry.roleID));
13511
+ pinned.push(entry.roleID);
13512
+ if (runtimes !== undefined && runtimes.length > 0) {
13513
+ const selected = await deps.prompt.multiselect({
13514
+ message: rolesInitMessages.runtimeQuestion(entry.roleID),
13515
+ choices: runtimes.map((runtime) => ({
13516
+ value: runtime.id,
13517
+ label: runtime.id,
13518
+ hint: rolesInitMessages.runtimeHint
13519
+ }))
13520
+ });
13521
+ if (selected.length > 0) {
13522
+ nextOverlay = withRole(nextOverlay ?? {}, entry.roleID, {
13523
+ localRuntimes: selected
13524
+ });
13525
+ }
13526
+ }
13527
+ }
13528
+ } catch (error) {
13529
+ const message = error instanceof Error ? error.message : String(error);
13530
+ lines.push(rolesInitMessages.cancelled(message));
13531
+ return { stdout: lines.join(`
13532
+ `), exitCode: 1 };
13533
+ }
13534
+ if (pinned.length === 0 && relaxed.length === 0) {
13535
+ lines.push(rolesInitMessages.nothingWritten(skipped.join(", ")));
13536
+ deps.prompt.outro(rolesInitMessages.nothingToWriteOutro);
13537
+ return { stdout: lines.join(`
13538
+ `), exitCode: 0 };
13539
+ }
13540
+ const merged = mergeConfigInputs(nextConfig, nextOverlay);
13541
+ try {
13542
+ loadOpenTeamConfig(merged);
13543
+ } catch (error) {
13544
+ lines.push(rolesInitMessages.invalidResult(error instanceof Error ? error.message : String(error)));
13545
+ return { stdout: lines.join(`
13546
+ `), exitCode: 1 };
13547
+ }
13548
+ await deps.writeFile(DEFAULT_CONFIG_PATH, serialize(nextConfig));
13549
+ if (nextOverlay !== undefined) {
13550
+ await deps.writeFile(DEFAULT_LOCAL_OVERLAY_PATH, serialize(nextOverlay));
13551
+ }
13552
+ if (pinned.length > 0) {
13553
+ lines.push(rolesInitMessages.pinnedLocal(pinned.join(", "), DEFAULT_CONFIG_PATH));
13554
+ }
13555
+ if (relaxed.length > 0) {
13556
+ lines.push(rolesInitMessages.frontierAllowed(relaxed.join(", "), DEFAULT_CONFIG_PATH));
13557
+ }
13558
+ if (nextOverlay !== undefined) {
13559
+ lines.push(rolesInitMessages.runtimeBinding(DEFAULT_LOCAL_OVERLAY_PATH));
13560
+ }
13561
+ if (skipped.length > 0) {
13562
+ lines.push(rolesInitMessages.skippedSummary(skipped.join(", ")));
13563
+ }
13564
+ deps.prompt.outro(rolesInitMessages.outro);
13565
+ return { stdout: lines.join(`
13566
+ `), exitCode: 0 };
13567
+ }
13568
+
13219
13569
  // src/config/legacyMigration.ts
13220
13570
  async function relocateNode(from, to, port, files) {
13221
13571
  if (!await port.exists(from)) {
@@ -14965,7 +15315,7 @@ async function buildMemoryRuntimeFromConfig(config, deps, options = {}) {
14965
15315
  // package.json
14966
15316
  var package_default = {
14967
15317
  name: "@jmanuelcorral/openteam",
14968
- version: "0.9.4",
15318
+ version: "0.11.0",
14969
15319
  packageManager: "bun@1.3.14",
14970
15320
  description: "Cost-aware, local-first routing plugin for opencode with cheapest-capable frontier fallback and multi-agent orchestration.",
14971
15321
  license: "MIT",
@@ -15382,6 +15732,20 @@ function createSetupDeps() {
15382
15732
  }
15383
15733
  };
15384
15734
  }
15735
+ function createRolesInitDeps() {
15736
+ const detect = createDetect(nodeExec2);
15737
+ return {
15738
+ prompt: clackPrompter,
15739
+ readFile: (path4) => storage.read(path4),
15740
+ writeFile: async (path4, contents) => {
15741
+ await storage.write(path4, contents);
15742
+ },
15743
+ detectRuntimes: async () => (await detect()).map((runtime) => ({
15744
+ id: runtime.id,
15745
+ available: runtime.reachable
15746
+ }))
15747
+ };
15748
+ }
15385
15749
  async function main() {
15386
15750
  const argv = process.argv.slice(2);
15387
15751
  const configSelection = parseConfigSelection(argv);
@@ -15389,6 +15753,15 @@ async function main() {
15389
15753
  const result2 = await runSetup(createSetupDeps());
15390
15754
  if (result2.stdout.length > 0) {
15391
15755
  process.stdout.write(`${result2.stdout}
15756
+ `);
15757
+ }
15758
+ process.exitCode = result2.exitCode;
15759
+ return;
15760
+ }
15761
+ if (argv[0] === "roles" && argv[1] === "init") {
15762
+ const result2 = await runRolesInit(createRolesInitDeps());
15763
+ if (result2.stdout.length > 0) {
15764
+ process.stdout.write(`${result2.stdout}
15392
15765
  `);
15393
15766
  }
15394
15767
  process.exitCode = result2.exitCode;