orchestrator-workflow 0.5.0 → 0.7.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/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to `orchestrator-workflow` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.7.0] - 2026-06-22
9
+
10
+ ### Added
11
+
12
+ - Machine-readable solution-acceptance status markers in the run templates:
13
+ `06-handoff.md` carries a `<!-- solution-acceptance: final-status = TODO -->`
14
+ line and `05-review-findings.md` carries a
15
+ `<!-- solution-acceptance: acceptance-recommendation = TODO -->` line. The
16
+ orchestrator replaces the `TODO` sentinel with the chosen enum value when
17
+ finalizing the handoff/review. This is the run-gate contract the harness
18
+ solution-acceptance gate reads, so a freshly-copied run is non-accepting by
19
+ construction (fail-closed). SKILL.md documents the finalization step.
20
+
21
+ ## [0.6.0]
22
+
23
+ ### Changed
24
+
25
+ - Portable opencode model resolution (#62).
26
+
27
+ ## [0.5.0]
28
+
29
+ ### Added
30
+
31
+ - Instruction trust boundary for the workflow, policy, and agent prompts (#55).
32
+
33
+ ## [0.4.0]
34
+
35
+ ### Added
36
+
37
+ - Read-only explorer/discovery role (#52).
38
+
39
+ ## [0.3.0]
40
+
41
+ ### Added
42
+
43
+ - Proportionality rule for delegation (#51).
44
+
45
+ ## [0.2.0]
46
+
47
+ ### Added
48
+
49
+ - Target-directory transparency and an `uninstall` command (#50).
50
+
51
+ ## [0.1.0]
52
+
53
+ ### Added
54
+
55
+ - Initial `orchestrator-workflow` package: `.ai/` run state, an `AGENTS.md`
56
+ policy section, and per-harness subagent definitions (#47).
package/INSTALL-AGENT.md CHANGED
@@ -49,7 +49,8 @@ The install creates or touches only these paths:
49
49
  - `.claude/skills/orchestrator-workflow/SKILL.md` and
50
50
  `.claude/agents/{explorer,task-slicer,implementer,reviewer}.md` (Claude Code)
51
51
  - `.agents/skills/orchestrator-workflow/SKILL.md` (Codex)
52
- - `.opencode/agents/{explorer,task-slicer,implementer,reviewer}.md` (opencode)
52
+ - `.opencode/skills/orchestrator-workflow/SKILL.md` and
53
+ `.opencode/agents/{explorer,task-slicer,implementer,reviewer}.md` (opencode)
53
54
 
54
55
  Nothing else in the repository is modified. Locally edited files are
55
56
  reported as conflicts and left alone, never overwritten silently; the
@@ -111,14 +112,31 @@ steps in the repository you were asked to install into.
111
112
  line directly after the `model:` line. Ensure `CLAUDE.md` exists and
112
113
  contains a line `@AGENTS.md`.
113
114
  - Codex: `.agents/skills/orchestrator-workflow/SKILL.md`, same skill file.
114
- - opencode: `.opencode/agents/<role>.md` from `assets/agents/<role>.md`,
115
- with the frontmatter rewritten to exactly this order: `description:`
116
- (unchanged), then `mode: subagent`, then `model: <provider/model-id>`;
117
- the `name:` line is dropped. For the explorer role additionally,
118
- `permission:` goes on a new line directly after the `model:` line,
119
- followed by ` edit: deny` (two-space indent) on the next line. Aliases
120
- map to `anthropic/claude-sonnet-4-6`, `anthropic/claude-opus-4-8`,
121
- `anthropic/claude-haiku-4-5`.
115
+ - opencode: `.opencode/skills/orchestrator-workflow/SKILL.md` from
116
+ `assets/skill/SKILL.md`, unchanged.
117
+ `.opencode/agents/<role>.md` from `assets/agents/<role>.md`, with the
118
+ frontmatter rewritten to this order: `description:` (unchanged), then
119
+ `mode: subagent`; the `name:` line is dropped. Only emit a
120
+ `model: <provider/model-id>` line when you have a fully-qualified id
121
+ (i.e. the value contains a `/`, such as
122
+ `github-copilot/claude-sonnet-4.6`). For a bare alias (`sonnet`, `opus`,
123
+ `haiku`) or any bare id without a provider prefix, **omit the `model:`
124
+ line entirely** — the subagent then inherits the session/default model,
125
+ which is the safe portable fallback. The installed CLI resolves aliases
126
+ to fully-qualified ids by running `opencode models` at install time; in a
127
+ manual install you may not have a live catalog, so omitting `model:` is
128
+ correct. For the explorer role additionally, `permission:` goes on a new
129
+ line directly after `mode: subagent` (or after `model:` when that line is
130
+ present), followed by ` edit: deny` (two-space indent) on the next line.
131
+ Example explorer frontmatter when no model is resolved:
132
+ ```yaml
133
+ ---
134
+ description: "..."
135
+ mode: subagent
136
+ permission:
137
+ edit: deny
138
+ ---
139
+ ```
122
140
  - `.ai/workflow/manifest.json`, exactly this shape (harnesses MUST be an
123
141
  array, models keyed by role, version = the kit version you installed):
124
142
 
package/README.md CHANGED
@@ -99,7 +99,7 @@ Per selected harness:
99
99
  |---|---|---|
100
100
  | Claude Code | `.claude/skills/orchestrator-workflow/SKILL.md`, `.claude/agents/{explorer,task-slicer,implementer,reviewer}.md`, `CLAUDE.md` | Claude Code reads `CLAUDE.md`, not `AGENTS.md`; the installer adds an additive `@AGENTS.md` import. Subagent models go into the `model:` frontmatter; the read-only explorer also gets `disallowedTools: Edit, Write, NotebookEdit`. |
101
101
  | OpenAI Codex | `.agents/skills/orchestrator-workflow/SKILL.md` | Codex reads `AGENTS.md` natively. There is no standardized project-level subagent definition; the skill instructs running the roles inline with the same contracts. |
102
- | opencode | `.opencode/agents/{explorer,task-slicer,implementer,reviewer}.md` | opencode reads `AGENTS.md` natively and cross-discovers `.claude/skills/`. Subagents get `mode: subagent` plus a fully qualified `provider/model-id`; the explorer also gets `permission: edit: deny`. |
102
+ | opencode | `.opencode/skills/orchestrator-workflow/SKILL.md`, `.opencode/agents/{explorer,task-slicer,implementer,reviewer}.md` | opencode reads `AGENTS.md` natively. Subagents get `mode: subagent`; the explorer also gets `permission: edit: deny`. Model resolution is described below. |
103
103
 
104
104
  ## Model preselection
105
105
 
@@ -113,11 +113,25 @@ Each subagent role gets a model, chosen interactively or via `--models`:
113
113
  | reviewer | `opus` | skeptical review benefits from the strongest model |
114
114
 
115
115
  The orchestrator itself runs on the session's main model; use the strongest
116
- reasoning model available. Aliases (`sonnet`, `opus`, `haiku`) map to fully
117
- qualified ids for opencode (for example `anthropic/claude-opus-4-8`). Custom
118
- ids pass through as given for Claude Code; for opencode, a bare id without a
119
- provider prefix gets `anthropic/` prepended. The chosen mapping is recorded
120
- in `.ai/workflow/manifest.json` and reused as the default on later re-runs.
116
+ reasoning model available. The chosen mapping is recorded in
117
+ `.ai/workflow/manifest.json` and reused as the default on later re-runs.
118
+
119
+ **opencode model resolution.** opencode requires fully-qualified `provider/model-id`
120
+ strings (e.g. `github-copilot/claude-sonnet-4.6`). At install time the CLI
121
+ runs `opencode models` to fetch the live catalog and auto-detects which
122
+ provider to use (the one that offers Claude models). When exactly one such
123
+ provider exists the aliases are resolved to the highest-version matching id in
124
+ the catalog. When multiple providers offer Claude models the CLI warns and asks
125
+ you to pass `--opencode-provider <id>` to disambiguate, or to supply
126
+ fully-qualified ids per role via `--models`. If no resolution is possible
127
+ (catalog empty, `opencode` binary absent, ambiguous provider) the `model:`
128
+ frontmatter line is omitted entirely and the subagent inherits the
129
+ session/default model — a safe, portable fallback. Fully-qualified ids in
130
+ `--models` always pass through unchanged regardless of the catalog.
131
+ Nested-path providers like `openrouter` (whose ids look like
132
+ `openrouter/anthropic/claude-...`) are not auto-resolved from aliases and must
133
+ be supplied as a fully-qualified `--models` entry, e.g.
134
+ `reviewer=openrouter/anthropic/claude-opus-4.8`.
121
135
 
122
136
  ## Ownership and re-runs
123
137
 
@@ -106,6 +106,12 @@ directory and the subagents.
106
106
  9. **Hand off.** Fill `06-handoff.md` and report to the operator: what changed,
107
107
  why, how it was verified, known risks, accepted waivers, suggested next step.
108
108
 
109
+ When finalizing `05-review-findings.md` and `06-handoff.md`, replace the `TODO`
110
+ in each `<!-- solution-acceptance: ... = TODO -->` marker with the chosen enum
111
+ value. That marker line is the machine-readable signal the harness
112
+ solution-acceptance run-gate reads, so leaving it as `TODO` keeps the run
113
+ non-accepting (fail-closed).
114
+
109
115
  ## Explorer output contract
110
116
 
111
117
  ```yaml
@@ -21,3 +21,5 @@
21
21
  ## Acceptance Recommendation
22
22
 
23
23
  accept | accept_with_notes | fix_required | reject
24
+
25
+ <!-- solution-acceptance: acceptance-recommendation = TODO -->
@@ -31,3 +31,5 @@
31
31
  ## Final Status
32
32
 
33
33
  accepted | accepted_with_notes | needs_followup | blocked
34
+
35
+ <!-- solution-acceptance: final-status = TODO -->
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import inquirer from "inquirer";
6
6
  import { PACKAGE_VERSION } from "./assets.js";
7
7
  import { HARNESSES, detectHarnesses, parseHarnessList } from "./detect.js";
8
8
  import { DEFAULT_MODELS, MODEL_ALIASES, ROLES, assertValidModelId, parseModelsSpec, } from "./models.js";
9
+ import { loadOpencodeCatalog, resolveOpencodeModels } from "./opencode.js";
9
10
  import { readInstalledManifest, runInit } from "./init.js";
10
11
  import { runUninstall } from "./uninstall.js";
11
12
  function isInteractive() {
@@ -101,6 +102,7 @@ program
101
102
  .option("-f, --force", "overwrite kit-owned files that have local edits")
102
103
  .option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: detected`)
103
104
  .option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
105
+ .option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
104
106
  .action(async (dir, opts) => {
105
107
  const targetDir = requireDirectory(dir);
106
108
  if (!targetDir)
@@ -147,11 +149,27 @@ program
147
149
  models = parseModelsSpec(opts.models, models);
148
150
  if (interactive && !opts.models)
149
151
  models = await promptModels(models);
152
+ // Resolve opencode model aliases against the live catalog when the opencode
153
+ // harness is selected. The shell-out stays here in the CLI so runInit
154
+ // remains pure.
155
+ let opencodeModels;
156
+ if (harnesses.includes("opencode")) {
157
+ const catalog = loadOpencodeCatalog();
158
+ const { resolved, warnings } = resolveOpencodeModels(models, {
159
+ catalog,
160
+ explicitProvider: opts.opencodeProvider,
161
+ });
162
+ opencodeModels = resolved;
163
+ for (const w of warnings) {
164
+ process.stderr.write(`Warning: ${w}\n`);
165
+ }
166
+ }
150
167
  const report = runInit({
151
168
  targetDir,
152
169
  harnesses,
153
170
  models,
154
171
  force: opts.force,
172
+ opencodeModels,
155
173
  });
156
174
  showPaths("Created", report.written);
157
175
  showPaths("Updated", report.updated);
package/dist/init.d.ts CHANGED
@@ -6,6 +6,14 @@ export interface InitOptions {
6
6
  harnesses: Harness[];
7
7
  models: Record<Role, string>;
8
8
  force?: boolean;
9
+ /**
10
+ * Resolved fully-qualified opencode model ids per role, or `undefined` to
11
+ * omit the `model:` frontmatter line (subagent inherits the session model).
12
+ * When this field is absent the fallback is `opencodeModelValue(models[role])`,
13
+ * which passes through a fully-qualified id and returns `undefined` for bare
14
+ * aliases, producing the same inherit-session-model behaviour for bare inputs.
15
+ */
16
+ opencodeModels?: Record<Role, string | undefined>;
9
17
  }
10
18
  export interface Manifest {
11
19
  kit: string;
package/dist/init.js CHANGED
@@ -97,14 +97,18 @@ function composeClaudeAgent(role, model) {
97
97
  frontmatter.push("---");
98
98
  return [...frontmatter, "", asset.body.trimEnd(), ""].join("\n");
99
99
  }
100
- function composeOpencodeAgent(role, model) {
100
+ function composeOpencodeAgent(role, modelValue) {
101
101
  const asset = readAgentAsset(role);
102
102
  const frontmatter = [
103
103
  "---",
104
104
  `description: ${yamlQuote(asset.description)}`,
105
105
  "mode: subagent",
106
- `model: ${opencodeModelValue(model)}`,
107
106
  ];
107
+ // Only emit `model:` when a resolved, non-empty FQ id is available.
108
+ // Omitting it lets the subagent inherit the session/default model.
109
+ if (modelValue) {
110
+ frontmatter.push(`model: ${modelValue}`);
111
+ }
108
112
  if (READ_ONLY_ROLES.has(role)) {
109
113
  frontmatter.push("permission:", " edit: deny");
110
114
  }
@@ -168,8 +172,12 @@ export function runInit(options) {
168
172
  installKitFile(join(".agents", "skills", SKILL_NAME, "SKILL.md"), skill);
169
173
  }
170
174
  if (options.harnesses.includes("opencode")) {
175
+ installKitFile(join(".opencode", "skills", SKILL_NAME, "SKILL.md"), skill);
171
176
  for (const role of ROLES) {
172
- installKitFile(join(".opencode", "agents", `${role}.md`), composeOpencodeAgent(role, options.models[role]));
177
+ const modelValue = options.opencodeModels !== undefined
178
+ ? options.opencodeModels[role]
179
+ : opencodeModelValue(options.models[role]);
180
+ installKitFile(join(".opencode", "agents", `${role}.md`), composeOpencodeAgent(role, modelValue));
173
181
  }
174
182
  }
175
183
  // The manifest records applied state, so it is written last and only when
package/dist/models.d.ts CHANGED
@@ -18,7 +18,14 @@ export declare function isModelAlias(value: string): value is ModelAlias;
18
18
  * full model ids, so the chosen value passes through unchanged.
19
19
  */
20
20
  export declare function claudeModelValue(model: string): string;
21
- export declare function opencodeModelValue(model: string): string;
21
+ /**
22
+ * opencode requires fully qualified `provider/model-id` strings. Returns the
23
+ * value unchanged when it already contains a provider prefix, or `undefined`
24
+ * for bare aliases and bare ids that cannot be resolved without a live
25
+ * catalog. A `undefined` return means the `model:` frontmatter line should be
26
+ * omitted so the subagent inherits the session model.
27
+ */
28
+ export declare function opencodeModelValue(model: string): string | undefined;
22
29
  /**
23
30
  * Model values are interpolated into YAML frontmatter as plain scalars;
24
31
  * reject anything that could break out of that position.
package/dist/models.js CHANGED
@@ -20,16 +20,6 @@ export const DEFAULT_MODELS = {
20
20
  implementer: "sonnet",
21
21
  reviewer: "opus",
22
22
  };
23
- /**
24
- * opencode expects fully qualified `provider/model-id` strings (models.dev
25
- * ids). These are the current Anthropic ids for the three aliases; targets
26
- * with a different provider setup can pass a custom id instead.
27
- */
28
- const OPENCODE_MODEL_IDS = {
29
- sonnet: "anthropic/claude-sonnet-4-6",
30
- opus: "anthropic/claude-opus-4-8",
31
- haiku: "anthropic/claude-haiku-4-5",
32
- };
33
23
  export function isModelAlias(value) {
34
24
  return MODEL_ALIASES.includes(value);
35
25
  }
@@ -40,10 +30,15 @@ export function isModelAlias(value) {
40
30
  export function claudeModelValue(model) {
41
31
  return model;
42
32
  }
33
+ /**
34
+ * opencode requires fully qualified `provider/model-id` strings. Returns the
35
+ * value unchanged when it already contains a provider prefix, or `undefined`
36
+ * for bare aliases and bare ids that cannot be resolved without a live
37
+ * catalog. A `undefined` return means the `model:` frontmatter line should be
38
+ * omitted so the subagent inherits the session model.
39
+ */
43
40
  export function opencodeModelValue(model) {
44
- if (isModelAlias(model))
45
- return OPENCODE_MODEL_IDS[model];
46
- return model.includes("/") ? model : `anthropic/${model}`;
41
+ return model.includes("/") ? model : undefined;
47
42
  }
48
43
  /**
49
44
  * Model values are interpolated into YAML frontmatter as plain scalars;
@@ -0,0 +1,66 @@
1
+ import type { ModelAlias, Role } from "./models.js";
2
+ /**
3
+ * Splits raw `opencode models` stdout into a list of trimmed non-empty lines,
4
+ * each of the form `provider/model-id`.
5
+ */
6
+ export declare function parseOpencodeCatalog(stdout: string): string[];
7
+ /**
8
+ * Returns the provider id: the substring before the first `/`.
9
+ * e.g. `github-copilot/claude-sonnet-4.6` → `github-copilot`
10
+ * `openrouter/anthropic/claude-sonnet-4.6` → `openrouter`
11
+ */
12
+ export declare function providerOf(modelLine: string): string;
13
+ /**
14
+ * Infers which provider to use for alias resolution.
15
+ *
16
+ * - If `explicit` is given, it is returned as-is (no catalog check).
17
+ * - Otherwise the catalog is scanned for providers that offer at least one
18
+ * model whose id segment (the part after `provider/`) starts with `claude-`.
19
+ * Exactly one such provider → use it.
20
+ * More than one → `{ provider: undefined, ambiguous: true }`.
21
+ * None → `{ provider: undefined, ambiguous: false }`.
22
+ */
23
+ export declare function detectProvider(opts: {
24
+ catalog: string[];
25
+ explicit?: string;
26
+ }): {
27
+ provider?: string;
28
+ ambiguous: boolean;
29
+ };
30
+ /**
31
+ * Finds the best fully-qualified `provider/model-id` line for a given alias
32
+ * from the live catalog.
33
+ *
34
+ * Selection rules:
35
+ * 1. Only lines starting with `${provider}/` whose remainder starts with the
36
+ * family token are candidates.
37
+ * 2. Non-canonical variants (`-fast`, `-thinking`, `-mini`, `-latest`) are
38
+ * excluded unless no canonical candidates remain.
39
+ * 3. Among remaining candidates, the one with the highest parsed version wins.
40
+ *
41
+ * Returns `undefined` when no matching line exists.
42
+ */
43
+ export declare function resolveAlias(provider: string, alias: ModelAlias, catalog: string[]): string | undefined;
44
+ /**
45
+ * Resolves each role's model string to a fully-qualified opencode id or
46
+ * `undefined` (meaning: omit `model:` and inherit the session model).
47
+ *
48
+ * - Already fully-qualified values (contain `/`) pass through unchanged.
49
+ * - Known aliases are resolved via the live catalog + provider detection.
50
+ * - Unknown bare strings (not an alias, no `/`) → `undefined` + warning.
51
+ * - When provider detection is ambiguous or yields nothing → `undefined` +
52
+ * one combined warning asking the user to pass `--opencode-provider <id>`
53
+ * or fully-qualified `--models`.
54
+ */
55
+ export declare function resolveOpencodeModels(roleModels: Record<Role, string>, opts: {
56
+ catalog: string[];
57
+ explicitProvider?: string;
58
+ }): {
59
+ resolved: Record<Role, string | undefined>;
60
+ warnings: string[];
61
+ };
62
+ /**
63
+ * Runs `opencode models` and returns the parsed catalog. Returns an empty
64
+ * array on any error (binary absent, non-zero exit, timeout, etc.).
65
+ */
66
+ export declare function loadOpencodeCatalog(): string[];
@@ -0,0 +1,200 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { isModelAlias } from "./models.js";
3
+ // ---------------------------------------------------------------------------
4
+ // Pure catalog helpers (unit-testable without shell access)
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * Splits raw `opencode models` stdout into a list of trimmed non-empty lines,
8
+ * each of the form `provider/model-id`.
9
+ */
10
+ export function parseOpencodeCatalog(stdout) {
11
+ return stdout
12
+ .split("\n")
13
+ .map((l) => l.trim())
14
+ .filter((l) => l.length > 0);
15
+ }
16
+ /**
17
+ * Returns the provider id: the substring before the first `/`.
18
+ * e.g. `github-copilot/claude-sonnet-4.6` → `github-copilot`
19
+ * `openrouter/anthropic/claude-sonnet-4.6` → `openrouter`
20
+ */
21
+ export function providerOf(modelLine) {
22
+ return modelLine.slice(0, modelLine.indexOf("/"));
23
+ }
24
+ /**
25
+ * Infers which provider to use for alias resolution.
26
+ *
27
+ * - If `explicit` is given, it is returned as-is (no catalog check).
28
+ * - Otherwise the catalog is scanned for providers that offer at least one
29
+ * model whose id segment (the part after `provider/`) starts with `claude-`.
30
+ * Exactly one such provider → use it.
31
+ * More than one → `{ provider: undefined, ambiguous: true }`.
32
+ * None → `{ provider: undefined, ambiguous: false }`.
33
+ */
34
+ export function detectProvider(opts) {
35
+ if (opts.explicit !== undefined) {
36
+ return { provider: opts.explicit, ambiguous: false };
37
+ }
38
+ const providers = new Set();
39
+ for (const line of opts.catalog) {
40
+ if (line.indexOf("/") <= 0)
41
+ continue;
42
+ const provider = providerOf(line);
43
+ const remainder = line.slice(provider.length + 1);
44
+ if (remainder.startsWith("claude-")) {
45
+ providers.add(provider);
46
+ }
47
+ }
48
+ if (providers.size === 1) {
49
+ return { provider: [...providers][0], ambiguous: false };
50
+ }
51
+ if (providers.size > 1) {
52
+ return { provider: undefined, ambiguous: true };
53
+ }
54
+ return { provider: undefined, ambiguous: false };
55
+ }
56
+ // Tags that mark non-canonical variants to skip when better options exist.
57
+ const NON_CANONICAL_MARKERS = ["-fast", "-thinking", "-mini", "-latest"];
58
+ function isNonCanonical(idSegment) {
59
+ return NON_CANONICAL_MARKERS.some((marker) => idSegment.endsWith(marker) || idSegment.includes(marker + "-"));
60
+ }
61
+ /** Parses the numeric version from a model id after removing the family token prefix. */
62
+ function parseVersion(idSegment, familyToken) {
63
+ const suffix = idSegment.slice(familyToken.length);
64
+ // Split on `.` and `-`, keep only numeric parts.
65
+ return suffix
66
+ .split(/[.\-]/)
67
+ .filter((p) => /^\d+$/.test(p))
68
+ .map(Number);
69
+ }
70
+ function compareVersions(a, b) {
71
+ const len = Math.max(a.length, b.length);
72
+ for (let i = 0; i < len; i++) {
73
+ const av = a[i] ?? 0;
74
+ const bv = b[i] ?? 0;
75
+ if (av !== bv)
76
+ return av - bv;
77
+ }
78
+ return 0;
79
+ }
80
+ const FAMILY_TOKENS = {
81
+ sonnet: "claude-sonnet",
82
+ opus: "claude-opus",
83
+ haiku: "claude-haiku",
84
+ };
85
+ /**
86
+ * Finds the best fully-qualified `provider/model-id` line for a given alias
87
+ * from the live catalog.
88
+ *
89
+ * Selection rules:
90
+ * 1. Only lines starting with `${provider}/` whose remainder starts with the
91
+ * family token are candidates.
92
+ * 2. Non-canonical variants (`-fast`, `-thinking`, `-mini`, `-latest`) are
93
+ * excluded unless no canonical candidates remain.
94
+ * 3. Among remaining candidates, the one with the highest parsed version wins.
95
+ *
96
+ * Returns `undefined` when no matching line exists.
97
+ */
98
+ export function resolveAlias(provider, alias, catalog) {
99
+ const familyToken = FAMILY_TOKENS[alias];
100
+ const prefix = `${provider}/`;
101
+ const allCandidates = catalog.filter((line) => {
102
+ if (!line.startsWith(prefix))
103
+ return false;
104
+ const remainder = line.slice(prefix.length);
105
+ return remainder.startsWith(familyToken);
106
+ });
107
+ if (allCandidates.length === 0)
108
+ return undefined;
109
+ const canonical = allCandidates.filter((line) => !isNonCanonical(line.slice(prefix.length)));
110
+ const candidates = canonical.length > 0 ? canonical : allCandidates;
111
+ let best;
112
+ let bestVersion = [];
113
+ for (const line of candidates) {
114
+ const remainder = line.slice(prefix.length);
115
+ const version = parseVersion(remainder, familyToken);
116
+ if (best === undefined || compareVersions(version, bestVersion) > 0) {
117
+ best = line;
118
+ bestVersion = version;
119
+ }
120
+ }
121
+ return best;
122
+ }
123
+ /**
124
+ * Resolves each role's model string to a fully-qualified opencode id or
125
+ * `undefined` (meaning: omit `model:` and inherit the session model).
126
+ *
127
+ * - Already fully-qualified values (contain `/`) pass through unchanged.
128
+ * - Known aliases are resolved via the live catalog + provider detection.
129
+ * - Unknown bare strings (not an alias, no `/`) → `undefined` + warning.
130
+ * - When provider detection is ambiguous or yields nothing → `undefined` +
131
+ * one combined warning asking the user to pass `--opencode-provider <id>`
132
+ * or fully-qualified `--models`.
133
+ */
134
+ export function resolveOpencodeModels(roleModels, opts) {
135
+ const { catalog, explicitProvider } = opts;
136
+ const resolved = {};
137
+ const warnings = [];
138
+ // Lazily detect provider once, shared across all roles.
139
+ let providerResult;
140
+ for (const [role, model] of Object.entries(roleModels)) {
141
+ if (model.includes("/")) {
142
+ // Already fully qualified — pass through.
143
+ resolved[role] = model;
144
+ continue;
145
+ }
146
+ if (!isModelAlias(model)) {
147
+ resolved[role] = undefined;
148
+ warnings.push(`Role "${role}": "${model}" is not a known alias and has no provider prefix; model: will be omitted (inherits session model).`);
149
+ continue;
150
+ }
151
+ // Known alias — need provider.
152
+ if (providerResult === undefined) {
153
+ providerResult = detectProvider({
154
+ catalog,
155
+ explicit: explicitProvider,
156
+ });
157
+ }
158
+ if (providerResult.provider === undefined) {
159
+ // Warning will be added once after the loop.
160
+ resolved[role] = undefined;
161
+ continue;
162
+ }
163
+ const fq = resolveAlias(providerResult.provider, model, catalog);
164
+ if (fq === undefined) {
165
+ resolved[role] = undefined;
166
+ warnings.push(`Role "${role}": provider "${providerResult.provider}" has no "${model}" model in the catalog; model: will be omitted.`);
167
+ }
168
+ else {
169
+ resolved[role] = fq;
170
+ }
171
+ }
172
+ // Emit one combined warning for ambiguous / no-provider cases.
173
+ if (providerResult !== undefined && providerResult.provider === undefined) {
174
+ const msg = providerResult.ambiguous
175
+ ? `Multiple providers offer Claude models in the live catalog; cannot auto-detect. Pass --opencode-provider <id> or use fully-qualified --models (e.g. provider/model-id) per role to resolve aliases.`
176
+ : `No provider offering Claude models found in the live catalog. Pass --opencode-provider <id> or use fully-qualified --models (e.g. provider/model-id) per role to resolve aliases.`;
177
+ warnings.push(msg);
178
+ }
179
+ return { resolved, warnings };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Impure shell-out (kept tiny and free of logic so tests avoid it)
183
+ // ---------------------------------------------------------------------------
184
+ /**
185
+ * Runs `opencode models` and returns the parsed catalog. Returns an empty
186
+ * array on any error (binary absent, non-zero exit, timeout, etc.).
187
+ */
188
+ export function loadOpencodeCatalog() {
189
+ try {
190
+ const stdout = execFileSync("opencode", ["models"], {
191
+ encoding: "utf8",
192
+ timeout: 10_000,
193
+ stdio: ["ignore", "pipe", "ignore"],
194
+ });
195
+ return parseOpencodeCatalog(stdout);
196
+ }
197
+ catch {
198
+ return [];
199
+ }
200
+ }
package/dist/uninstall.js CHANGED
@@ -81,6 +81,8 @@ const PRUNE_CANDIDATES = [
81
81
  join(".agents", "skills", "orchestrator-workflow"),
82
82
  join(".agents", "skills"),
83
83
  ".agents",
84
+ join(".opencode", "skills", "orchestrator-workflow"),
85
+ join(".opencode", "skills"),
84
86
  join(".opencode", "agents"),
85
87
  ".opencode",
86
88
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrator-workflow",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Installer for an orchestrator-led agent workflow: .ai/ run state, an AGENTS.md policy section, and per-harness subagent definitions for Claude Code, OpenAI Codex, and opencode",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "dist",
12
12
  "assets",
13
13
  "README.md",
14
- "INSTALL-AGENT.md"
14
+ "INSTALL-AGENT.md",
15
+ "CHANGELOG.md"
15
16
  ],
16
17
  "scripts": {
17
18
  "build": "tsc",
@@ -51,7 +52,7 @@
51
52
  "@types/inquirer": "^9.0.7",
52
53
  "@types/node": "^20.11.0",
53
54
  "prettier": "^3.8.1",
54
- "tsx": "^4.19.3",
55
+ "tsx": "^4.22.4",
55
56
  "typescript": "^5.3.3",
56
57
  "vitest": "^4.1.6"
57
58
  }