@jmanuelcorral/openteam 0.9.1 → 0.9.3

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.1`; versión actual del paquete |
822
+ | openteam | `0.9.3`; 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.1`; current package version |
703
+ | openteam | `0.9.3`; 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
@@ -205,7 +205,7 @@ var LEGACY_TEAM_ROLE_ALIASES = {
205
205
  basher: "routing-cost",
206
206
  linus: "tester"
207
207
  };
208
- var KNOWN_NON_WORKER_ROLE_IDS = ["openteam"];
208
+ var KNOWN_NON_WORKER_ROLE_IDS = ["orchestrator", "openteam"];
209
209
  function isKnownNonWorkerRole(roleID) {
210
210
  return KNOWN_NON_WORKER_ROLE_IDS.includes(roleID);
211
211
  }
@@ -8648,22 +8648,70 @@ function buildOrchestratorAgent(frontier, options = {}) {
8648
8648
  "1. When you create the team for the first time, **choose a thematic universe**",
8649
8649
  " (e.g. a film, series, comic or mythology). If the user has a preference,",
8650
8650
  " ask them for it; if not, propose one and move forward without blocking work.",
8651
- "2. Give each subagent the **name of a character** from that universe; the",
8652
- " file name (`.opencode/agent/<name>.md`) is its alias for",
8651
+ "2. Give each subagent an `agentName`: the **name of a character** from that universe;",
8652
+ " the file name (`.opencode/agent/<agentName>.md`) is its alias for",
8653
8653
  " `@mention`, and its role is clear from the `description`.",
8654
- `3. **Register the cast** in \`${OPENTEAM_ROSTER_PATH}\` (universe + table`,
8655
- " name role model) so that the names **persist** across sessions.",
8656
- " This file goes **outside** `.opencode/agent/` (otherwise opencode would load it",
8657
- " as a phantom agent). Always reuse the same cast; do not re-cast",
8658
- " without reason.",
8654
+ "3. Give each entry a `roleID`: the stable functional routing key. `roleID` is",
8655
+ " **not** the themed name. Worked example: C-3PO as the scribe is",
8656
+ ' `{ "roleID": "scribe", "agentName": "c3po" }`, never',
8657
+ ' `{ "roleID": "c3po", "agentName": "c3po" }`.',
8658
+ "4. Prefer existing curated `roleID` keys when they fit:",
8659
+ " `architect`, `integration`, `tester`, `reviewer`, `local-runtime`,",
8660
+ " `routing-cost` (plus the guaranteed `orchestrator`, `guardian`,",
8661
+ " `scribe`, `ralph`). The roleID space is open: use free-form project",
8662
+ " 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.",
8667
+ "",
8668
+ "## Roster file format",
8669
+ "",
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:",
8673
+ "",
8674
+ "# openteam roster",
8675
+ "",
8676
+ "Universe: star-wars",
8677
+ "",
8678
+ "```json",
8679
+ "{",
8680
+ ' "universe": "star-wars",',
8681
+ ' "entries": [',
8682
+ " {",
8683
+ ' "roleID": "orchestrator",',
8684
+ ' "agentName": "openteam"',
8685
+ " },",
8686
+ " {",
8687
+ ' "roleID": "guardian",',
8688
+ ' "agentName": "leia"',
8689
+ " },",
8690
+ " {",
8691
+ ' "roleID": "scribe",',
8692
+ ' "agentName": "c3po"',
8693
+ " },",
8694
+ " {",
8695
+ ' "roleID": "ralph",',
8696
+ ' "agentName": "r2d2"',
8697
+ " },",
8698
+ " {",
8699
+ ' "roleID": "tester",',
8700
+ ' "agentName": "rex"',
8701
+ " }",
8702
+ " ]",
8703
+ "}",
8704
+ "```",
8659
8705
  "",
8660
8706
  "## How to create the team when it does not exist",
8661
8707
  "",
8662
8708
  "1. Analyze the goal and explore the repository (`read`/`glob`/`grep`).",
8663
8709
  "2. Break the request into tasks and design the **minimal team** needed:",
8664
- " define only the roles the work requires (e.g. architect, backend,",
8665
- " frontend, reviewer, tester, docs). Do not invent roles you will not use.",
8666
- "3. Create each subagent **on demand** by writing `.opencode/agent/<name>.md`:",
8710
+ " define only the roles the work requires. Reuse curated roleIDs such as",
8711
+ " `architect`, `integration`, `reviewer`, `tester`, `local-runtime` and",
8712
+ " `routing-cost`; use custom roleIDs for uncovered project specialties.",
8713
+ " Do not invent roles you will not use.",
8714
+ "3. Create each subagent **on demand** by writing `.opencode/agent/<agentName>.md`:",
8667
8715
  " - Frontmatter: `description` (required, include role and universe),",
8668
8716
  " `mode: subagent`, `temperature`, `permission` with the minimum needed,",
8669
8717
  " and `model` **optional**.",
@@ -8684,8 +8732,10 @@ function buildOrchestratorAgent(frontier, options = {}) {
8684
8732
  "",
8685
8733
  "## Standard team roles",
8686
8734
  "",
8687
- "Besides the project-specific roles, include these standard roles",
8688
- "(create them on demand, with their universe name) when they add value:",
8735
+ "Every roster MUST include these guaranteed canonical roleIDs exactly:",
8736
+ "`orchestrator`, `guardian`, `scribe`, and `ralph`. Map `orchestrator` to",
8737
+ "agentName `openteam` (the primary agent). Create the `guardian`, `scribe`,",
8738
+ "and `ralph` subagent files on demand with their themed `agentName` values:",
8689
8739
  "",
8690
8740
  "- **scribe** — the team's silent memory. Records decisions and",
8691
8741
  " learnings in a shared log (`.opencode/openteam/decisions.md`) without",
@@ -9896,16 +9946,165 @@ var RosterSchema = z17.object({
9896
9946
  universe: z17.string().min(1),
9897
9947
  entries: z17.array(RosterEntrySchema)
9898
9948
  }).strict();
9949
+ var ROSTER_JSON_FENCE = /```json\s*([\s\S]*?)```/;
9950
+
9951
+ class RosterParseError extends Error {
9952
+ code;
9953
+ constructor(code, message) {
9954
+ super(message);
9955
+ this.name = "RosterParseError";
9956
+ this.code = code;
9957
+ }
9958
+ }
9959
+ function parseRoster(text2) {
9960
+ const result = parseRosterResult(text2);
9961
+ if (!result.ok) {
9962
+ throw result.error;
9963
+ }
9964
+ return result.roster;
9965
+ }
9966
+ function parseRosterResult(text2) {
9967
+ const match = ROSTER_JSON_FENCE.exec(text2);
9968
+ if (match === null || match[1] === undefined) {
9969
+ return {
9970
+ ok: false,
9971
+ error: new RosterParseError("missing-json-block", "openteam roster: no JSON block found in roster file")
9972
+ };
9973
+ }
9974
+ let data;
9975
+ try {
9976
+ data = JSON.parse(match[1]);
9977
+ } catch (error) {
9978
+ return {
9979
+ ok: false,
9980
+ error: new RosterParseError("malformed-json", `openteam roster: malformed JSON in roster file: ${String(error)}`)
9981
+ };
9982
+ }
9983
+ const parsed = RosterSchema.safeParse(data);
9984
+ if (!parsed.success) {
9985
+ return {
9986
+ ok: false,
9987
+ error: new RosterParseError("invalid-roster-schema", `openteam roster: invalid roster schema: ${parsed.error.message}`)
9988
+ };
9989
+ }
9990
+ return { ok: true, roster: parsed.data };
9991
+ }
9899
9992
  var GUARANTEED_ROLE_IDS = [
9900
9993
  "orchestrator",
9901
9994
  "guardian",
9902
9995
  "scribe",
9903
9996
  "ralph"
9904
9997
  ];
9998
+ var GUARANTEED_ROLE_DEFAULT_AGENT_NAMES = {
9999
+ orchestrator: "openteam",
10000
+ guardian: "guardian",
10001
+ scribe: "scribe",
10002
+ ralph: "ralph"
10003
+ };
10004
+ function defaultAgentNameForGuaranteedRole(roleID) {
10005
+ return GUARANTEED_ROLE_DEFAULT_AGENT_NAMES[roleID];
10006
+ }
10007
+ function isGuaranteedRoleID(roleID) {
10008
+ return GUARANTEED_ROLE_IDS.includes(roleID);
10009
+ }
9905
10010
  function missingGuaranteedRoles(roster) {
9906
10011
  const present = new Set(roster.entries.map((entry) => entry.roleID));
9907
10012
  return GUARANTEED_ROLE_IDS.filter((roleID) => !present.has(roleID));
9908
10013
  }
10014
+ function isRoleProfileLike(value) {
10015
+ return typeof value === "object" && value !== null && "roleID" in value && "defaultTier" in value;
10016
+ }
10017
+ function configuredRolesFromAuditInput(input) {
10018
+ if ("configuredRoles" in input && !isRoleProfileLike(input.configuredRoles)) {
10019
+ return input.configuredRoles;
10020
+ }
10021
+ return input;
10022
+ }
10023
+ function auditRoster(rosterOrText, configuredRolesOrOptions = {}) {
10024
+ const configuredRoles = configuredRolesFromAuditInput(configuredRolesOrOptions);
10025
+ if (typeof rosterOrText === "string") {
10026
+ const parsed = parseRosterResult(rosterOrText);
10027
+ if (!parsed.ok) {
10028
+ return {
10029
+ findings: [
10030
+ {
10031
+ kind: "unparseable-roster",
10032
+ roleID: "roster",
10033
+ code: parsed.error.code,
10034
+ message: parsed.error.message
10035
+ }
10036
+ ],
10037
+ hasAllGuaranteedRoles: false,
10038
+ missingGuaranteedRoleIDs: [],
10039
+ unprofiledRoleEntries: [],
10040
+ misassignedGuaranteedRoleEntries: []
10041
+ };
10042
+ }
10043
+ return auditParsedRoster(parsed.roster, configuredRoles);
10044
+ }
10045
+ return auditParsedRoster(rosterOrText, configuredRoles);
10046
+ }
10047
+ function auditParsedRoster(roster, configuredRoles) {
10048
+ const missingGuaranteedRoleIDs = missingGuaranteedRoles(roster);
10049
+ const missing = new Set(missingGuaranteedRoleIDs);
10050
+ const findings = missingGuaranteedRoleIDs.map((roleID) => ({
10051
+ kind: "missing-guaranteed-role",
10052
+ roleID
10053
+ }));
10054
+ const unprofiledRoleEntries = [];
10055
+ const misassignedGuaranteedRoleEntries = [];
10056
+ roster.entries.forEach((entry) => {
10057
+ const profile = getRoleProfile(entry.roleID, configuredRoles);
10058
+ if (profile === undefined && !isKnownNonWorkerRole(entry.roleID)) {
10059
+ unprofiledRoleEntries.push({
10060
+ roleID: entry.roleID,
10061
+ agentName: entry.agentName,
10062
+ severity: "info"
10063
+ });
10064
+ findings.push({
10065
+ kind: "unresolved-role-profile",
10066
+ roleID: entry.roleID,
10067
+ agentName: entry.agentName
10068
+ });
10069
+ }
10070
+ if (isGuaranteedRoleID(entry.agentName) && entry.roleID !== entry.agentName && missing.has(entry.agentName)) {
10071
+ misassignedGuaranteedRoleEntries.push({
10072
+ roleID: entry.roleID,
10073
+ agentName: entry.agentName,
10074
+ missingRoleID: entry.agentName,
10075
+ reason: "agentNameMatchesMissingGuaranteedRole"
10076
+ });
10077
+ findings.push({
10078
+ kind: "non-canonical-role-id",
10079
+ roleID: entry.roleID,
10080
+ agentName: entry.agentName,
10081
+ canonicalRoleID: entry.agentName
10082
+ });
10083
+ return;
10084
+ }
10085
+ if (profile !== undefined && isGuaranteedRoleID(profile.roleID) && profile.roleID !== entry.roleID && missing.has(profile.roleID)) {
10086
+ misassignedGuaranteedRoleEntries.push({
10087
+ roleID: entry.roleID,
10088
+ agentName: entry.agentName,
10089
+ missingRoleID: profile.roleID,
10090
+ reason: "roleIDResolvesToMissingGuaranteedRole"
10091
+ });
10092
+ findings.push({
10093
+ kind: "non-canonical-role-id",
10094
+ roleID: entry.roleID,
10095
+ agentName: entry.agentName,
10096
+ canonicalRoleID: profile.roleID
10097
+ });
10098
+ }
10099
+ });
10100
+ return {
10101
+ findings,
10102
+ hasAllGuaranteedRoles: missingGuaranteedRoleIDs.length === 0,
10103
+ missingGuaranteedRoleIDs,
10104
+ unprofiledRoleEntries,
10105
+ misassignedGuaranteedRoleEntries
10106
+ };
10107
+ }
9909
10108
  function enforceGuaranteedRoles(roster) {
9910
10109
  const missing = missingGuaranteedRoles(roster);
9911
10110
  if (missing.length === 0) {
@@ -9913,7 +10112,7 @@ function enforceGuaranteedRoles(roster) {
9913
10112
  }
9914
10113
  const added = missing.map((roleID) => ({
9915
10114
  roleID,
9916
- agentName: roleID
10115
+ agentName: defaultAgentNameForGuaranteedRole(roleID)
9917
10116
  }));
9918
10117
  return { universe: roster.universe, entries: [...roster.entries, ...added] };
9919
10118
  }
@@ -10346,6 +10545,82 @@ function diagnosticsSection(diagnostics) {
10346
10545
  lines.push(" note: plugin diagnostics are bounded classes recorded instead of writing to the opencode TUI console.");
10347
10546
  return lines;
10348
10547
  }
10548
+ function rosterEntryText(entry) {
10549
+ return entry.agentName === undefined ? `"${entry.roleID}"` : `"${entry.roleID}" (agentName "${entry.agentName}")`;
10550
+ }
10551
+ function canonicalRoleID(entry) {
10552
+ return entry.canonicalRoleID ?? entry.missingRoleID;
10553
+ }
10554
+ function legacyRosterFindings(audit) {
10555
+ const findings = [];
10556
+ for (const roleID of audit.missingGuaranteedRoleIDs ?? []) {
10557
+ findings.push({ kind: "missing-guaranteed-role", roleID });
10558
+ }
10559
+ for (const entry of audit.misassignedGuaranteedRoleEntries ?? []) {
10560
+ const canonical2 = canonicalRoleID(entry);
10561
+ findings.push({
10562
+ kind: "non-canonical-role-id",
10563
+ roleID: entry.roleID,
10564
+ ...entry.agentName !== undefined ? { agentName: entry.agentName } : {},
10565
+ ...canonical2 !== undefined ? { canonicalRoleID: canonical2 } : {}
10566
+ });
10567
+ }
10568
+ for (const entry of audit.unprofiledRoleEntries ?? []) {
10569
+ findings.push({
10570
+ kind: "unresolved-role-profile",
10571
+ roleID: entry.roleID,
10572
+ ...entry.agentName !== undefined ? { agentName: entry.agentName } : {}
10573
+ });
10574
+ }
10575
+ return findings;
10576
+ }
10577
+ function rosterHealthSection(audit, loadError, rosterRoleCount) {
10578
+ const auditFindings = audit?.findings ?? legacyRosterFindings(audit ?? {});
10579
+ const unparseable = auditFindings.find((finding) => finding.kind === "unparseable-roster");
10580
+ const unparseableMessage = loadError ?? unparseable?.message;
10581
+ if (unparseableMessage !== undefined) {
10582
+ return [
10583
+ " roster health:",
10584
+ ` ✗ unparseable roster: ${OPENTEAM_ROSTER_PATH} — ${unparseableMessage}`,
10585
+ " problem: the roster is present but is not machine-readable by openteam.",
10586
+ " remedy: keep any human notes outside the JSON fence, add one fenced `json` block containing `{ universe, entries: [{ roleID, agentName }] }`, then re-run `openteam doctor`."
10587
+ ];
10588
+ }
10589
+ if (audit === undefined) {
10590
+ return [];
10591
+ }
10592
+ const missing = auditFindings.filter((finding) => finding.kind === "missing-guaranteed-role").map((finding) => finding.roleID);
10593
+ const misassigned = auditFindings.filter((finding) => finding.kind === "non-canonical-role-id");
10594
+ const unprofiled = auditFindings.filter((finding) => finding.kind === "unresolved-role-profile");
10595
+ const hasErrorLevelFindings = missing.length > 0 || misassigned.length > 0;
10596
+ const hasFindings = hasErrorLevelFindings || unprofiled.length > 0;
10597
+ if (!hasFindings) {
10598
+ return rosterRoleCount === undefined ? [] : [
10599
+ " roster health:",
10600
+ ` ✓ ${OPENTEAM_ROSTER_PATH} read (${rosterRoleCount} roles); guaranteed roles present.`
10601
+ ];
10602
+ }
10603
+ const lines = [" roster health:"];
10604
+ if (!hasErrorLevelFindings && rosterRoleCount !== undefined) {
10605
+ lines.push(` ✓ ${OPENTEAM_ROSTER_PATH} read (${rosterRoleCount} roles); guaranteed roles present.`);
10606
+ }
10607
+ if (missing.length > 0) {
10608
+ lines.push(` ✗ missing guaranteed roleID(s): ${missing.join(", ")}`);
10609
+ lines.push(` remedy: edit ${OPENTEAM_ROSTER_PATH}'s JSON block to add these canonical roleIDs with their themed agentName values; keep roleID canonical, e.g. { "roleID": "scribe", "agentName": "c3po" }.`);
10610
+ }
10611
+ for (const entry of misassigned) {
10612
+ const canonical2 = canonicalRoleID(entry);
10613
+ const target = canonical2 === undefined ? "the canonical functional roleID" : `"${canonical2}"`;
10614
+ lines.push(` ⚠ non-canonical roleID ${rosterEntryText(entry)}`);
10615
+ lines.push(` remedy: change roleID to ${target} and keep the themed name in agentName.`);
10616
+ }
10617
+ if (unprofiled.length > 0) {
10618
+ const roleIDs = unprofiled.map((entry) => entry.roleID).join(", ");
10619
+ 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.");
10621
+ }
10622
+ return lines;
10623
+ }
10349
10624
  function agentModelsSection(diagnostics, searchedPaths) {
10350
10625
  const lines = [" agent models:"];
10351
10626
  if (diagnostics.length === 0) {
@@ -10384,6 +10659,7 @@ function renderDoctor(input) {
10384
10659
  if (input.diagnostics !== undefined) {
10385
10660
  lines.push(...diagnosticsSection(input.diagnostics));
10386
10661
  }
10662
+ lines.push(...rosterHealthSection(input.rosterAudit, input.rosterLoadError, input.rosterRoleCount));
10387
10663
  if (input.otelBackend !== undefined) {
10388
10664
  const otel = input.otelBackend;
10389
10665
  lines.push(" opentelemetry:");
@@ -12609,13 +12885,23 @@ async function guaranteedAgentStatuses(runtime) {
12609
12885
  }
12610
12886
  return statuses;
12611
12887
  }
12888
+ function rosterPlanSummary(rosterFile) {
12889
+ switch (rosterFile.status) {
12890
+ case "loaded":
12891
+ return `${rosterFile.roster.universe} (${rosterFile.roster.entries.length} roles)`;
12892
+ case "absent":
12893
+ return "none yet — one would be generated on the first real run";
12894
+ case "invalid":
12895
+ return `${rosterFile.path} is present but unparseable — run 'openteam doctor'`;
12896
+ }
12897
+ }
12612
12898
  function renderLoopPlan(plan2) {
12613
12899
  const { flags } = plan2;
12614
12900
  const budget = flags.budgetUsd !== undefined ? `$${flags.budgetUsd}` : "unbounded";
12615
12901
  const freshContext = flags.freshContext ? "on (pure Ralph pattern)" : "off";
12616
12902
  const budgetStop = flags.budgetUsd !== undefined ? ", budget" : "";
12617
12903
  const redTestsStop = flags.stopOnRedTests ? ", red tests" : "";
12618
- const rosterSummary = plan2.roster !== undefined ? `${plan2.roster.universe} (${plan2.roster.entries.length} roles)` : "none yet — one would be generated on the first real run";
12904
+ const rosterSummary = rosterPlanSummary(plan2.rosterFile);
12619
12905
  const lines = [
12620
12906
  "openteam loop — plan (dry run, nothing dispatched):",
12621
12907
  ` backlog: ${flags.backlogPath} (${plan2.uncheckedCount} unchecked item(s))`,
@@ -12659,14 +12945,14 @@ ${LOOP_HELP}` };
12659
12945
  const backlogText = await runtime.readBacklog(flags.backlogPath) ?? "";
12660
12946
  const items = parseBacklog2(backlogText);
12661
12947
  const unchecked = uncheckedBacklogItems(items);
12662
- const roster = await runtime.loadRoster();
12948
+ const rosterFile = await runtime.loadRosterFile();
12663
12949
  const guaranteed = await guaranteedAgentStatuses(runtime);
12664
12950
  const plannedIterations = Math.max(0, Math.min(flags.maxIterations, unchecked.length));
12665
12951
  const plan2 = {
12666
12952
  flags,
12667
12953
  uncheckedCount: unchecked.length,
12668
12954
  plannedIterations,
12669
- roster,
12955
+ rosterFile,
12670
12956
  guaranteed
12671
12957
  };
12672
12958
  if (flags.dryRun) {
@@ -12744,13 +13030,15 @@ async function runCli(argv, deps) {
12744
13030
  if (command === "doctor") {
12745
13031
  const config = await deps.loadConfig(configPath, configResolution);
12746
13032
  let opencodeConfigError;
13033
+ let rosterLoadError;
12747
13034
  const [
12748
13035
  snapshots,
12749
13036
  records,
12750
13037
  diagnostics,
12751
13038
  cacheEntries,
12752
13039
  agentFiles,
12753
- opencodeConfig
13040
+ opencodeConfig,
13041
+ roster
12754
13042
  ] = await Promise.all([
12755
13043
  deps.probe(config),
12756
13044
  deps.readTelemetry(telemetryPath),
@@ -12762,7 +13050,11 @@ async function runCli(argv, deps) {
12762
13050
  deps.readOpencodeConfig(opencodeConfigPaths).catch((err) => {
12763
13051
  opencodeConfigError = err instanceof Error ? err.message : String(err);
12764
13052
  return;
12765
- })
13053
+ }),
13054
+ deps.loadRosterForDoctor !== undefined ? deps.loadRosterForDoctor().catch((err) => {
13055
+ rosterLoadError = err instanceof Error ? err.message : String(err);
13056
+ return;
13057
+ }) : Promise.resolve(undefined)
12766
13058
  ]);
12767
13059
  const defaultModel = defaultModelFromOpencode(opencodeConfig);
12768
13060
  const agentModels = diagnoseAgentModels(agentFiles, {
@@ -12777,6 +13069,18 @@ async function runCli(argv, deps) {
12777
13069
  backend: "opentelemetry",
12778
13070
  connectionEnv: "APPLICATIONINSIGHTS_CONNECTION_STRING"
12779
13071
  }, (name) => process.env[name]);
13072
+ let rosterAudit;
13073
+ let rosterRoleCount;
13074
+ if (roster !== undefined && deps.auditRoster !== undefined) {
13075
+ if (typeof roster !== "string") {
13076
+ rosterRoleCount = roster.entries.length;
13077
+ }
13078
+ try {
13079
+ rosterAudit = deps.auditRoster(roster, config.orchestrator?.roles);
13080
+ } catch (err) {
13081
+ rosterLoadError = err instanceof Error ? err.message : String(err);
13082
+ }
13083
+ }
12780
13084
  return {
12781
13085
  exitCode: 0,
12782
13086
  stdout: renderDoctor({
@@ -12786,6 +13090,9 @@ async function runCli(argv, deps) {
12786
13090
  telemetryRecords: records.length,
12787
13091
  agentModels,
12788
13092
  opencodeConfigPaths,
13093
+ ...rosterAudit !== undefined ? { rosterAudit } : {},
13094
+ ...rosterRoleCount !== undefined ? { rosterRoleCount } : {},
13095
+ ...rosterLoadError !== undefined ? { rosterLoadError } : {},
12789
13096
  ...diagnostics !== undefined && diagnostics.length > 0 ? { diagnostics } : {},
12790
13097
  ...opencodeConfigError !== undefined ? { opencodeConfigError } : {},
12791
13098
  ...cacheEntries !== undefined ? { cacheEntries, cliVersion: deps.version } : {},
@@ -14349,7 +14656,6 @@ async function* linesFromReadable(readable) {
14349
14656
  }
14350
14657
 
14351
14658
  // src/orchestrator/rosterPersistence.ts
14352
- var ROSTER_JSON_FENCE = /```json\s*([\s\S]*?)```/;
14353
14659
  function serializeRoster(roster) {
14354
14660
  const data = { universe: roster.universe, entries: roster.entries };
14355
14661
  const json = JSON.stringify(data, null, 2);
@@ -14362,38 +14668,41 @@ ${json}
14362
14668
  \`\`\`
14363
14669
  `;
14364
14670
  }
14365
- function parseRoster(text2) {
14366
- const match = ROSTER_JSON_FENCE.exec(text2);
14367
- if (match === null || match[1] === undefined) {
14368
- throw new Error("openteam roster: no JSON block found in roster file");
14369
- }
14370
- let data;
14371
- try {
14372
- data = JSON.parse(match[1]);
14373
- } catch (error) {
14374
- throw new Error(`openteam roster: malformed JSON in roster file: ${String(error)}`);
14375
- }
14376
- return RosterSchema.parse(data);
14377
- }
14378
14671
  async function persistRoster(storage, roster, path4 = OPENTEAM_ROSTER_PATH) {
14379
14672
  await storage.write(path4, serializeRoster(roster));
14380
14673
  return path4;
14381
14674
  }
14382
- async function loadRoster(storage, path4 = OPENTEAM_ROSTER_PATH) {
14675
+ async function loadRosterFile(storage, path4 = OPENTEAM_ROSTER_PATH) {
14383
14676
  const content = await storage.read(path4);
14384
14677
  if (content === undefined) {
14385
- return;
14678
+ return { status: "absent", path: path4 };
14679
+ }
14680
+ const parsed = parseRosterResult(content);
14681
+ if (!parsed.ok) {
14682
+ return { status: "invalid", path: path4, error: parsed.error };
14386
14683
  }
14387
- return parseRoster(content);
14684
+ return {
14685
+ status: "loaded",
14686
+ path: path4,
14687
+ roster: enforceGuaranteedRoles(parsed.roster)
14688
+ };
14388
14689
  }
14389
14690
  async function loadOrGenerateRoster(storage, generate, path4 = OPENTEAM_ROSTER_PATH) {
14390
- const existing = await loadRoster(storage, path4);
14391
- if (existing !== undefined) {
14392
- return { roster: existing, regenerated: false };
14691
+ const existing = await loadRosterFile(storage, path4);
14692
+ if (existing.status === "loaded") {
14693
+ return { roster: existing.roster, regenerated: false };
14694
+ }
14695
+ const roster = enforceGuaranteedRoles(await generate());
14696
+ if (existing.status === "absent") {
14697
+ await persistRoster(storage, roster, path4);
14698
+ return { roster, regenerated: true };
14393
14699
  }
14394
- const roster = await generate();
14395
- await persistRoster(storage, roster, path4);
14396
- return { roster, regenerated: true };
14700
+ return {
14701
+ roster,
14702
+ regenerated: true,
14703
+ recoveredFromInvalidPersisted: true,
14704
+ invalidPersistedRosterError: existing.error.message
14705
+ };
14397
14706
  }
14398
14707
 
14399
14708
  // src/plugin/orchestrateTool.ts
@@ -14649,7 +14958,7 @@ async function buildMemoryRuntimeFromConfig(config, deps, options = {}) {
14649
14958
  // package.json
14650
14959
  var package_default = {
14651
14960
  name: "@jmanuelcorral/openteam",
14652
- version: "0.9.1",
14961
+ version: "0.9.3",
14653
14962
  packageManager: "bun@1.3.14",
14654
14963
  description: "Cost-aware, local-first routing plugin for opencode with cheapest-capable frontier fallback and multi-agent orchestration.",
14655
14964
  license: "MIT",
@@ -14774,6 +15083,18 @@ function createGitLastCommit(exec) {
14774
15083
  var execFileAsync2 = promisify2(execFile2);
14775
15084
  var AGENT_DIR2 = dirname5(ORCHESTRATOR_AGENT_PATH);
14776
15085
  var storage = createFsStorageProvider(process.cwd());
15086
+ async function readStoredRosterForDoctor() {
15087
+ const content = await storage.read(OPENTEAM_ROSTER_PATH);
15088
+ if (content === undefined) {
15089
+ return;
15090
+ }
15091
+ try {
15092
+ return parseRoster(content);
15093
+ } catch {
15094
+ return content;
15095
+ }
15096
+ }
15097
+ var auditStoredRosterForDoctor = (roster, configuredRoles) => typeof roster === "string" ? auditRoster(roster, configuredRoles) : auditRoster(roster, configuredRoles);
14777
15098
  var migratePort = createMigrateAdapter(process.cwd());
14778
15099
  var nodeExec2 = async (command, args) => {
14779
15100
  try {
@@ -14952,6 +15273,8 @@ var deps = {
14952
15273
  cachePort: realCacheAdapter,
14953
15274
  purge: createCliPurgeRuntime(),
14954
15275
  loopRuntime: createCliLoopRuntime(),
15276
+ loadRosterForDoctor: readStoredRosterForDoctor,
15277
+ auditRoster: auditStoredRosterForDoctor,
14955
15278
  version: PACKAGE_VERSION
14956
15279
  };
14957
15280
  function createCliPurgeRuntime() {
@@ -15021,7 +15344,7 @@ function createCliLoopRuntime() {
15021
15344
  const agentFileExists = (agentName) => storage.exists(`${AGENT_DIR2}/${agentName}.md`);
15022
15345
  const generate = () => generateRoster("openteam initial roster", fallbackRosterSource);
15023
15346
  return {
15024
- loadRoster: () => loadRoster(storage),
15347
+ loadRosterFile: () => loadRosterFile(storage),
15025
15348
  loadOrGenerateRoster: async () => (await loadOrGenerateRoster(storage, generate)).roster,
15026
15349
  agentFileExists,
15027
15350
  readBacklog: (path4) => storage.read(path4),
@@ -4,12 +4,14 @@ import type { LegacyLayoutFinding } from "../config/legacyPaths";
4
4
  import type { OpenTeamConfig } from "../config/schema";
5
5
  import type { LocalRuntimeSnapshot } from "../local/types";
6
6
  import type { ActiveIngressCapability, GraphIngress, IngressPolicy } from "../orchestrator/graphIngress";
7
+ import { type AgentRoleProfile } from "../orchestrator/roles";
7
8
  import { type Roster } from "../orchestrator/roster";
9
+ import type { RosterFileLoadResult } from "../orchestrator/rosterPersistence";
8
10
  import { type WorktreeObservation, type WorktreeReconcileAction } from "../orchestrator/worktreeReconciler";
9
11
  import type { CostRecord } from "../telemetry/types";
10
12
  import { type AgentFile } from "./agents";
11
13
  import type { CachePort } from "./clearCache";
12
- import { type DoctorDiagnostic } from "./doctor";
14
+ import { type DoctorDiagnostic, type DoctorRosterAudit } from "./doctor";
13
15
  import { type GraphHealthSnapshot } from "./graph";
14
16
  import { type PurgeRuntime } from "./purge";
15
17
  export type CliDeps = {
@@ -22,6 +24,18 @@ export type CliDeps = {
22
24
  * older composition roots still run; when absent, doctor omits the section.
23
25
  */
24
26
  readDiagnostics?: () => Promise<DoctorDiagnostic[]>;
27
+ /**
28
+ * Reads the persisted roster for `openteam doctor`. This may be raw Markdown:
29
+ * the pure audit seam classifies unparseable table-only rosters without
30
+ * throwing, preserving the human-authored file while doctor reports a remedy.
31
+ */
32
+ loadRosterForDoctor?: () => Promise<Roster | string | undefined>;
33
+ /**
34
+ * Pure roster audit seam from `src/orchestrator/roster.ts`. Doctor receives the
35
+ * structured result and only renders it; audit logic stays in the roster
36
+ * boundary.
37
+ */
38
+ auditRoster?: (roster: Roster | string, configuredRoles?: Readonly<Record<string, AgentRoleProfile>>) => DoctorRosterAudit;
25
39
  /**
26
40
  * Reads and deep-merges all candidate opencode config files (later entries in
27
41
  * `paths` have lower precedence). Returns `undefined` when none of the paths
@@ -197,8 +211,8 @@ export type LoopRunResult = {
197
211
  * implementation (see `src/cli.ts`); tests inject a deterministic fake.
198
212
  */
199
213
  export type LoopRuntime = {
200
- /** Reads the persisted roster WITHOUT contacting a model. `undefined` = none. */
201
- loadRoster(): Promise<Roster | undefined>;
214
+ /** Reads the persisted roster status WITHOUT contacting a model. */
215
+ loadRosterFile(): Promise<RosterFileLoadResult>;
202
216
  /** Reuses the persisted roster, or generates + persists one (may call a model). */
203
217
  loadOrGenerateRoster(): Promise<Roster>;
204
218
  /** Whether `.opencode/agent/<agentName>.md` exists in the workspace. */
@@ -215,7 +229,7 @@ export type LoopPlan = {
215
229
  readonly flags: LoopFlags;
216
230
  readonly uncheckedCount: number;
217
231
  readonly plannedIterations: number;
218
- readonly roster: Roster | undefined;
232
+ readonly rosterFile: RosterFileLoadResult;
219
233
  readonly guaranteed: readonly GuaranteedAgentStatus[];
220
234
  };
221
235
  /** Renders the loop plan for `--dry-run`. Pure. */