@jmanuelcorral/openteam 0.9.1 → 0.9.2

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.2`; 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.2`; 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,77 @@ 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
+ if (missing.length === 0 && misassigned.length === 0 && unprofiled.length === 0) {
10596
+ return rosterRoleCount === undefined ? [] : [
10597
+ " roster health:",
10598
+ ` ✓ ${OPENTEAM_ROSTER_PATH} read (${rosterRoleCount} roles); guaranteed roles present.`
10599
+ ];
10600
+ }
10601
+ const lines = [" roster health:"];
10602
+ if (missing.length > 0) {
10603
+ lines.push(` ✗ missing guaranteed roleID(s): ${missing.join(", ")}`);
10604
+ 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" }.`);
10605
+ }
10606
+ for (const entry of misassigned) {
10607
+ const canonical2 = canonicalRoleID(entry);
10608
+ const target = canonical2 === undefined ? "the canonical functional roleID" : `"${canonical2}"`;
10609
+ lines.push(` ⚠ non-canonical roleID ${rosterEntryText(entry)}`);
10610
+ lines.push(` remedy: change roleID to ${target} and keep the themed name in agentName.`);
10611
+ }
10612
+ if (unprofiled.length > 0) {
10613
+ const roleIDs = unprofiled.map((entry) => entry.roleID).join(", ");
10614
+ lines.push(` · unprofiled roleID(s): ${roleIDs}`);
10615
+ 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.");
10616
+ }
10617
+ return lines;
10618
+ }
10349
10619
  function agentModelsSection(diagnostics, searchedPaths) {
10350
10620
  const lines = [" agent models:"];
10351
10621
  if (diagnostics.length === 0) {
@@ -10384,6 +10654,7 @@ function renderDoctor(input) {
10384
10654
  if (input.diagnostics !== undefined) {
10385
10655
  lines.push(...diagnosticsSection(input.diagnostics));
10386
10656
  }
10657
+ lines.push(...rosterHealthSection(input.rosterAudit, input.rosterLoadError, input.rosterRoleCount));
10387
10658
  if (input.otelBackend !== undefined) {
10388
10659
  const otel = input.otelBackend;
10389
10660
  lines.push(" opentelemetry:");
@@ -12609,13 +12880,23 @@ async function guaranteedAgentStatuses(runtime) {
12609
12880
  }
12610
12881
  return statuses;
12611
12882
  }
12883
+ function rosterPlanSummary(rosterFile) {
12884
+ switch (rosterFile.status) {
12885
+ case "loaded":
12886
+ return `${rosterFile.roster.universe} (${rosterFile.roster.entries.length} roles)`;
12887
+ case "absent":
12888
+ return "none yet — one would be generated on the first real run";
12889
+ case "invalid":
12890
+ return `${rosterFile.path} is present but unparseable — run 'openteam doctor'`;
12891
+ }
12892
+ }
12612
12893
  function renderLoopPlan(plan2) {
12613
12894
  const { flags } = plan2;
12614
12895
  const budget = flags.budgetUsd !== undefined ? `$${flags.budgetUsd}` : "unbounded";
12615
12896
  const freshContext = flags.freshContext ? "on (pure Ralph pattern)" : "off";
12616
12897
  const budgetStop = flags.budgetUsd !== undefined ? ", budget" : "";
12617
12898
  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";
12899
+ const rosterSummary = rosterPlanSummary(plan2.rosterFile);
12619
12900
  const lines = [
12620
12901
  "openteam loop — plan (dry run, nothing dispatched):",
12621
12902
  ` backlog: ${flags.backlogPath} (${plan2.uncheckedCount} unchecked item(s))`,
@@ -12659,14 +12940,14 @@ ${LOOP_HELP}` };
12659
12940
  const backlogText = await runtime.readBacklog(flags.backlogPath) ?? "";
12660
12941
  const items = parseBacklog2(backlogText);
12661
12942
  const unchecked = uncheckedBacklogItems(items);
12662
- const roster = await runtime.loadRoster();
12943
+ const rosterFile = await runtime.loadRosterFile();
12663
12944
  const guaranteed = await guaranteedAgentStatuses(runtime);
12664
12945
  const plannedIterations = Math.max(0, Math.min(flags.maxIterations, unchecked.length));
12665
12946
  const plan2 = {
12666
12947
  flags,
12667
12948
  uncheckedCount: unchecked.length,
12668
12949
  plannedIterations,
12669
- roster,
12950
+ rosterFile,
12670
12951
  guaranteed
12671
12952
  };
12672
12953
  if (flags.dryRun) {
@@ -12744,13 +13025,15 @@ async function runCli(argv, deps) {
12744
13025
  if (command === "doctor") {
12745
13026
  const config = await deps.loadConfig(configPath, configResolution);
12746
13027
  let opencodeConfigError;
13028
+ let rosterLoadError;
12747
13029
  const [
12748
13030
  snapshots,
12749
13031
  records,
12750
13032
  diagnostics,
12751
13033
  cacheEntries,
12752
13034
  agentFiles,
12753
- opencodeConfig
13035
+ opencodeConfig,
13036
+ roster
12754
13037
  ] = await Promise.all([
12755
13038
  deps.probe(config),
12756
13039
  deps.readTelemetry(telemetryPath),
@@ -12762,7 +13045,11 @@ async function runCli(argv, deps) {
12762
13045
  deps.readOpencodeConfig(opencodeConfigPaths).catch((err) => {
12763
13046
  opencodeConfigError = err instanceof Error ? err.message : String(err);
12764
13047
  return;
12765
- })
13048
+ }),
13049
+ deps.loadRosterForDoctor !== undefined ? deps.loadRosterForDoctor().catch((err) => {
13050
+ rosterLoadError = err instanceof Error ? err.message : String(err);
13051
+ return;
13052
+ }) : Promise.resolve(undefined)
12766
13053
  ]);
12767
13054
  const defaultModel = defaultModelFromOpencode(opencodeConfig);
12768
13055
  const agentModels = diagnoseAgentModels(agentFiles, {
@@ -12777,6 +13064,18 @@ async function runCli(argv, deps) {
12777
13064
  backend: "opentelemetry",
12778
13065
  connectionEnv: "APPLICATIONINSIGHTS_CONNECTION_STRING"
12779
13066
  }, (name) => process.env[name]);
13067
+ let rosterAudit;
13068
+ let rosterRoleCount;
13069
+ if (roster !== undefined && deps.auditRoster !== undefined) {
13070
+ if (typeof roster !== "string") {
13071
+ rosterRoleCount = roster.entries.length;
13072
+ }
13073
+ try {
13074
+ rosterAudit = deps.auditRoster(roster, config.orchestrator?.roles);
13075
+ } catch (err) {
13076
+ rosterLoadError = err instanceof Error ? err.message : String(err);
13077
+ }
13078
+ }
12780
13079
  return {
12781
13080
  exitCode: 0,
12782
13081
  stdout: renderDoctor({
@@ -12786,6 +13085,9 @@ async function runCli(argv, deps) {
12786
13085
  telemetryRecords: records.length,
12787
13086
  agentModels,
12788
13087
  opencodeConfigPaths,
13088
+ ...rosterAudit !== undefined ? { rosterAudit } : {},
13089
+ ...rosterRoleCount !== undefined ? { rosterRoleCount } : {},
13090
+ ...rosterLoadError !== undefined ? { rosterLoadError } : {},
12789
13091
  ...diagnostics !== undefined && diagnostics.length > 0 ? { diagnostics } : {},
12790
13092
  ...opencodeConfigError !== undefined ? { opencodeConfigError } : {},
12791
13093
  ...cacheEntries !== undefined ? { cacheEntries, cliVersion: deps.version } : {},
@@ -14349,7 +14651,6 @@ async function* linesFromReadable(readable) {
14349
14651
  }
14350
14652
 
14351
14653
  // src/orchestrator/rosterPersistence.ts
14352
- var ROSTER_JSON_FENCE = /```json\s*([\s\S]*?)```/;
14353
14654
  function serializeRoster(roster) {
14354
14655
  const data = { universe: roster.universe, entries: roster.entries };
14355
14656
  const json = JSON.stringify(data, null, 2);
@@ -14362,38 +14663,41 @@ ${json}
14362
14663
  \`\`\`
14363
14664
  `;
14364
14665
  }
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
14666
  async function persistRoster(storage, roster, path4 = OPENTEAM_ROSTER_PATH) {
14379
14667
  await storage.write(path4, serializeRoster(roster));
14380
14668
  return path4;
14381
14669
  }
14382
- async function loadRoster(storage, path4 = OPENTEAM_ROSTER_PATH) {
14670
+ async function loadRosterFile(storage, path4 = OPENTEAM_ROSTER_PATH) {
14383
14671
  const content = await storage.read(path4);
14384
14672
  if (content === undefined) {
14385
- return;
14673
+ return { status: "absent", path: path4 };
14674
+ }
14675
+ const parsed = parseRosterResult(content);
14676
+ if (!parsed.ok) {
14677
+ return { status: "invalid", path: path4, error: parsed.error };
14386
14678
  }
14387
- return parseRoster(content);
14679
+ return {
14680
+ status: "loaded",
14681
+ path: path4,
14682
+ roster: enforceGuaranteedRoles(parsed.roster)
14683
+ };
14388
14684
  }
14389
14685
  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 };
14686
+ const existing = await loadRosterFile(storage, path4);
14687
+ if (existing.status === "loaded") {
14688
+ return { roster: existing.roster, regenerated: false };
14689
+ }
14690
+ const roster = enforceGuaranteedRoles(await generate());
14691
+ if (existing.status === "absent") {
14692
+ await persistRoster(storage, roster, path4);
14693
+ return { roster, regenerated: true };
14393
14694
  }
14394
- const roster = await generate();
14395
- await persistRoster(storage, roster, path4);
14396
- return { roster, regenerated: true };
14695
+ return {
14696
+ roster,
14697
+ regenerated: true,
14698
+ recoveredFromInvalidPersisted: true,
14699
+ invalidPersistedRosterError: existing.error.message
14700
+ };
14397
14701
  }
14398
14702
 
14399
14703
  // src/plugin/orchestrateTool.ts
@@ -14649,7 +14953,7 @@ async function buildMemoryRuntimeFromConfig(config, deps, options = {}) {
14649
14953
  // package.json
14650
14954
  var package_default = {
14651
14955
  name: "@jmanuelcorral/openteam",
14652
- version: "0.9.1",
14956
+ version: "0.9.2",
14653
14957
  packageManager: "bun@1.3.14",
14654
14958
  description: "Cost-aware, local-first routing plugin for opencode with cheapest-capable frontier fallback and multi-agent orchestration.",
14655
14959
  license: "MIT",
@@ -14774,6 +15078,18 @@ function createGitLastCommit(exec) {
14774
15078
  var execFileAsync2 = promisify2(execFile2);
14775
15079
  var AGENT_DIR2 = dirname5(ORCHESTRATOR_AGENT_PATH);
14776
15080
  var storage = createFsStorageProvider(process.cwd());
15081
+ async function readStoredRosterForDoctor() {
15082
+ const content = await storage.read(OPENTEAM_ROSTER_PATH);
15083
+ if (content === undefined) {
15084
+ return;
15085
+ }
15086
+ try {
15087
+ return parseRoster(content);
15088
+ } catch {
15089
+ return content;
15090
+ }
15091
+ }
15092
+ var auditStoredRosterForDoctor = (roster, configuredRoles) => typeof roster === "string" ? auditRoster(roster, configuredRoles) : auditRoster(roster, configuredRoles);
14777
15093
  var migratePort = createMigrateAdapter(process.cwd());
14778
15094
  var nodeExec2 = async (command, args) => {
14779
15095
  try {
@@ -14952,6 +15268,8 @@ var deps = {
14952
15268
  cachePort: realCacheAdapter,
14953
15269
  purge: createCliPurgeRuntime(),
14954
15270
  loopRuntime: createCliLoopRuntime(),
15271
+ loadRosterForDoctor: readStoredRosterForDoctor,
15272
+ auditRoster: auditStoredRosterForDoctor,
14955
15273
  version: PACKAGE_VERSION
14956
15274
  };
14957
15275
  function createCliPurgeRuntime() {
@@ -15021,7 +15339,7 @@ function createCliLoopRuntime() {
15021
15339
  const agentFileExists = (agentName) => storage.exists(`${AGENT_DIR2}/${agentName}.md`);
15022
15340
  const generate = () => generateRoster("openteam initial roster", fallbackRosterSource);
15023
15341
  return {
15024
- loadRoster: () => loadRoster(storage),
15342
+ loadRosterFile: () => loadRosterFile(storage),
15025
15343
  loadOrGenerateRoster: async () => (await loadOrGenerateRoster(storage, generate)).roster,
15026
15344
  agentFileExists,
15027
15345
  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. */