orchestrator-workflow 0.18.0 → 0.19.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 CHANGED
@@ -5,6 +5,73 @@ All notable changes to `orchestrator-workflow` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.19.0] - 2026-08-19
9
+
10
+ ### Added
11
+
12
+ - `init` gains `--tiers`: renders an additional per-role subagent variant
13
+ file for each non-default effort tier, alongside the one default
14
+ (unsuffixed) agent file `--profile` already installs. Off by default, like
15
+ every optional pack in this kit; a plain re-run with no `--tiers` flag
16
+ keeps whatever the previous install had, the same override-vs-persist rule
17
+ already used for `--profile`/`--models`, and there is no interactive
18
+ prompt, tiers is opt-in via the flag only. `models.ts` adds `Tier`
19
+ (`low|medium|high|xhigh`), `ROLE_TIERS` (which tiers each role gets:
20
+ explorer/task-slicer `low,medium,high`; implementer all four;
21
+ reviewer `medium,high,xhigh`), `DEFAULT_TIER` (the tier each role's plain
22
+ file already corresponds to: `medium` for explorer/task-slicer/implementer,
23
+ `high` for reviewer, never rendered as its own variant since that would
24
+ both collide with and duplicate the default file), `TIER_DEFS` (tier ->
25
+ model class + requested effort), and `CLASS_MODELS` (model class -> model
26
+ alias: `small`->`haiku`, `medium`->`sonnet`, `large`->`opus`). The default
27
+ file for every role stays byte-identical to a tiers-off install (still
28
+ `manifest.models[role]`, no `effort:` key) whether or not `--tiers` is
29
+ passed, so the reviewer's `opus` default can never be silently downgraded
30
+ by this feature; a dedicated regression test pins that. Variant files are
31
+ named `<role>-<tier>.md`; with `--profile full` and tiers on, that is 4
32
+ default files plus 9 variants (13 total). Claude Code variants carry
33
+ `model: <class alias>` and `effort: <tier>` frontmatter (plus
34
+ `disallowedTools: Edit, Write, NotebookEdit` for the read-only roles, same
35
+ as the default file). opencode variants key off the resolved model's
36
+ family, not its provider prefix: a Claude-family id (any provider fronting
37
+ a `claude-`-named model, e.g. `anthropic/claude-...`,
38
+ `github-copilot/claude-...`, or the nested
39
+ `openrouter/anthropic/claude-...`) gets `variant: high`/`variant: max` for
40
+ the `high`/`xhigh` tiers only (`low`/`medium` collapse to no effort field,
41
+ a documented opencode `variant:` limitation, not a bug), Ollama or a provider-less
42
+ id gets no effort field, and every other non-Claude-family model gets a plain
43
+ `reasoningEffort: <tier>` line; the variant's `model:` line resolves
44
+ through the same live `opencode models` catalog lookup as the base
45
+ per-role model, keyed by the tier's model class instead of by role. A tier
46
+ whose class model cannot be resolved at all renders no variant file for
47
+ that class, not a file with the `model:` line simply omitted, and the CLI
48
+ warns once per unresolved class on stderr; this guard and its warning
49
+ are opencode-scoped only, since Claude Code variants resolve `model:` from
50
+ a plain alias and need no live catalog lookup. The chosen value is
51
+ recorded in a new `tiers` boolean on
52
+ `.ai/workflow/manifest.json`; a manifest written before tiers existed (no
53
+ `tiers` key) degrades to `false`, the same per-field-degradation style
54
+ already used for a missing `profile` field. Variant files flow through the
55
+ existing `installKitFile` hash ledger, so idempotence, conflict detection,
56
+ and `uninstall` all cover them automatically with no dedicated code.
57
+ Motivated by a harness capability probe (2026-08-19): Claude Code's
58
+ `effort:` subagent frontmatter is wire-verified to reach the model request
59
+ as `output_config.effort`, which is what makes rendering per-tier
60
+ frontmatter variants worth doing at all, but the same probe also found
61
+ that the `CLAUDE_CODE_EFFORT_LEVEL` environment variable always overrides
62
+ frontmatter `effort:` on every installed agent when set, tier variants and
63
+ default files alike; README's new "Effort tiers" section documents that
64
+ override explicitly as a warning, not a footnote. README documents the
65
+ flag, the role/tier table, the tier -> model class/effort table, and the
66
+ opencode provider behavior; `INSTALL-AGENT.md` documents `--tiers` in the
67
+ init question/example and the manifest JSON shape, and states the manual
68
+ fallback path does not render tier variants at all. A new, narrowly scoped
69
+ `docs-consistency.test.ts` check enumerates the README tier table against
70
+ `ROLE_TIERS`/`DEFAULT_TIER` directly, so a role or tier added to either
71
+ without a matching table update fails loudly. Both OKF bundle docs
72
+ touching the installer (`model-preselection.md`,
73
+ `install-fence-mechanics.md`) are re-verified and re-stamped.
74
+
8
75
  ## [0.18.0] - 2026-08-18
9
76
 
10
77
  ### Changed
package/INSTALL-AGENT.md CHANGED
@@ -22,19 +22,22 @@ which is mutable. For a stable audit, pin the URL to a commit SHA instead
22
22
  below).
23
23
  2. **Ask you, not guess**: which harnesses should get adapters, which role
24
24
  profile to install (`full` — every role, or `minimal` — implementer and
25
- reviewer only; the reviewer is never optional), and which model each
26
- installed subagent role should use. Suggested defaults: profile `full`;
27
- explorer `sonnet`, task-slicer `sonnet`, implementer `sonnet`, reviewer
28
- `opus`.
25
+ reviewer only; the reviewer is never optional), which model each
26
+ installed subagent role should use, and whether to also render effort-tier
27
+ subagent variants (`--tiers`; off by default, no per-tier model prompt
28
+ since tier models are chosen automatically). Suggested defaults: profile
29
+ `full`; explorer `sonnet`, task-slicer `sonnet`, implementer `sonnet`,
30
+ reviewer `opus`; tiers off.
29
31
  3. **Run the non-interactive installer** with your answers:
30
- `npx orchestrator-workflow init --yes --harness ... --profile ... --models ...`.
32
+ `npx orchestrator-workflow init --yes --harness ... --profile ... --models ... [--tiers]`.
31
33
  If the installer reports conflicts with locally edited files, the agent
32
34
  shows them to you and asks before any `--force` re-run.
33
35
  4. **Manual fallback only when npx or the registry is unavailable**: create
34
36
  the same files by hand from this repository's `assets/` directory,
35
37
  following the byte-precise rules in step 4 below.
36
38
  5. **Report back**: which harnesses were installed, which profile and model
37
- each role uses, and any conflicts left in place.
39
+ each role uses, whether effort-tier variants were rendered, and any
40
+ conflicts left in place.
38
41
 
39
42
  ### Write surface
40
43
 
@@ -58,7 +61,16 @@ The per-role agent files above are the `full` profile (the default); the
58
61
  `minimal` profile writes only the `implementer` and `reviewer` files for
59
62
  Claude Code and opencode and skips `task-slicer` and `explorer` entirely.
60
63
  Codex has no per-role files, so the profile choice does not change what it
61
- gets. Nothing else in the repository is modified. Locally edited files are
64
+ gets. When `--tiers` is on, each installed Claude Code and opencode role
65
+ additionally gets one subagent file per non-default effort tier, named
66
+ `<role>-<tier>.md` (never a file for the role's own default tier, which
67
+ would collide with the plain `<role>.md` file); see the package README's
68
+ "Effort tiers" section for the full role/tier table and the per-harness
69
+ frontmatter shape. `--tiers` is off by default and has no interactive
70
+ prompt equivalent in the manual fallback below (nor does its negation,
71
+ `--no-tiers`): the automated installer is the only path that renders
72
+ tier-variant files; a manual scaffold (step 4) does not cover them.
73
+ Nothing else in the repository is modified. Locally edited files are
62
74
  reported as conflicts and left alone, never overwritten silently; the
63
75
  exceptions are the kit-owned surfaces: `.ai/workflow/manifest.json` (the
64
76
  kit's state file, rewritten whenever the applied state changes) and the
@@ -90,6 +102,10 @@ steps in the repository you were asked to install into.
90
102
  `sonnet`, reviewer `opus`. Accept the aliases `sonnet`, `opus`,
91
103
  `haiku` or a full model id. Skip asking about a role's model when the
92
104
  chosen profile does not install that role.
105
+ - Whether to also render effort-tier subagent variants (`--tiers`)?
106
+ Default: off. There is no per-tier model question: tier models are
107
+ chosen automatically from the tier (see the package README's "Effort
108
+ tiers" section for the role/tier table and the model-class mapping).
93
109
 
94
110
  3. Run the non-interactive installer with the operator's answers:
95
111
 
@@ -97,16 +113,25 @@ steps in the repository you were asked to install into.
97
113
  npx orchestrator-workflow init --yes \
98
114
  --harness <claude,codex,opencode> \
99
115
  --profile <minimal|full> \
100
- --models "explorer=<model>,task-slicer=<model>,implementer=<model>,reviewer=<model>"
116
+ --models "explorer=<model>,task-slicer=<model>,implementer=<model>,reviewer=<model>" \
117
+ [--tiers | --no-tiers]
101
118
  ```
102
119
 
103
120
  Omit `--profile` to keep `full` (or, on a re-run, whatever profile was
104
121
  installed previously); omit the models for roles the chosen profile does
105
- not install. If the command reports conflicts, show them to the operator
122
+ not install. Add `--tiers` only when the operator asked for tier
123
+ variants; add `--no-tiers` only when the operator explicitly wants them
124
+ turned off on a re-run that previously had them on; omit both to keep
125
+ tiers off on a fresh install, or whatever value was previously installed
126
+ on a re-run. If the command reports conflicts, show them to the operator
106
127
  and ask before re-running with --force.
107
128
 
108
129
  4. Only if npx or the registry is unavailable, scaffold manually from
109
- https://github.com/LanNguyenSi/agent-dx/tree/master/packages/orchestrator-workflow/assets
130
+ https://github.com/LanNguyenSi/agent-dx/tree/master/packages/orchestrator-workflow/assets.
131
+ This manual path does not cover `--tiers`: it never renders
132
+ `<role>-<tier>.md` variant files, regardless of what the operator asked
133
+ for in step 2; tell the operator tier variants require the automated
134
+ installer (step 3).
110
135
 
111
136
  - `.ai/workflow/templates/00-goal.md` through `06-handoff.md` from
112
137
  `assets/templates/`, unchanged.
@@ -164,6 +189,7 @@ steps in the repository you were asked to install into.
164
189
  "version": "0.5.0",
165
190
  "harnesses": ["claude", "opencode"],
166
191
  "profile": "full",
192
+ "tiers": false,
167
193
  "models": {
168
194
  "explorer": "sonnet",
169
195
  "task-slicer": "sonnet",
@@ -177,11 +203,14 @@ steps in the repository you were asked to install into.
177
203
 
178
204
  Under `minimal`, `models` only needs the `implementer` and `reviewer`
179
205
  keys (the roles actually installed); the missing keys fall back to the
180
- kit's defaults if the profile is later switched back to `full`.
206
+ kit's defaults if the profile is later switched back to `full`. `tiers`
207
+ is always `false` from this manual path, since it never renders
208
+ tier-variant files (see step 4's opening note above).
181
209
 
182
210
  A manual install may leave the `files` hash map empty; a later `init`
183
211
  run then treats existing kit files conservatively and reports conflicts
184
212
  rather than overwriting them.
185
213
 
186
214
  5. Report back to the operator: which harnesses were installed, which model
187
- each role uses, and any conflicts that were left in place.
215
+ each role uses, whether effort-tier variants were rendered, and any
216
+ conflicts that were left in place.
package/README.md CHANGED
@@ -195,6 +195,103 @@ Nested-path providers like `openrouter` (whose ids look like
195
195
  be supplied as a fully-qualified `--models` entry, e.g.
196
196
  `reviewer=openrouter/anthropic/claude-opus-4.8`.
197
197
 
198
+ ## Effort tiers
199
+
200
+ `--tiers` renders an additional per-role subagent definition for each
201
+ non-default effort tier, alongside the one default (unsuffixed) agent file
202
+ `--profile` already installs. Each tier variant is a standalone subagent
203
+ definition, not a modification of the default file: the default file
204
+ (`<role>.md`) stays byte-identical to what a tiers-off install already
205
+ produces (still `manifest.models[role]`, no `effort:` key), and each variant
206
+ lives next to it as `<role>-<tier>.md`.
207
+
208
+ Default off, like every optional pack in this kit: a fresh install renders
209
+ no variant files unless asked. `--tiers` turns the feature on for that run,
210
+ `--no-tiers` turns it off; a plain re-run with neither flag keeps whatever
211
+ the previous install had, the same override-vs-persist rule already used
212
+ for `--profile` and `--models`. There is no interactive prompt for it:
213
+ `tiers` is opt-in/off via the flags only.
214
+
215
+ ```bash
216
+ npx orchestrator-workflow init --tiers --yes
217
+ ```
218
+
219
+ Turning tiers back off with `--no-tiers` after having them on follows the
220
+ same pattern as a `full` → `minimal` profile downgrade: `init` prints a note
221
+ naming the now-untracked `<role>-<tier>.md` variant files and how to remove
222
+ them, rather than deleting them or leaving the leftover unexplained.
223
+
224
+ **Which tiers each role gets.** A role never gets a variant file for its own
225
+ default tier: that would collide with, and duplicate, the default file.
226
+
227
+ | Role | Tiers available | Default tier (no variant file) |
228
+ |---|---|---|
229
+ | explorer | low, medium, high | medium |
230
+ | task-slicer | low, medium, high | medium |
231
+ | implementer | low, medium, high, xhigh | medium |
232
+ | reviewer | medium, high, xhigh | high |
233
+
234
+ With `--profile full` and every tier rendered, that is 4 default files plus
235
+ 9 variant files: 13 files total per harness.
236
+
237
+ **Tier → model class → effort.** Each tier resolves to a model class and an
238
+ effort value:
239
+
240
+ | Tier | Model class | Model alias | Effort requested |
241
+ |---|---|---|---|
242
+ | low | small | `haiku` | `low` |
243
+ | medium | medium | `sonnet` | `medium` |
244
+ | high | medium | `sonnet` | `high` |
245
+ | xhigh | large | `opus` | `xhigh` |
246
+
247
+ Claude Code variants carry both a `model:` line (the class's alias) and an
248
+ `effort: <tier>` line in frontmatter. Read-only roles (explorer, reviewer)
249
+ keep `disallowedTools: Edit, Write, NotebookEdit` on their variants too.
250
+
251
+ **opencode variants key off the resolved model's family, not its provider
252
+ prefix**, since opencode's effort surface is not uniform across model
253
+ families:
254
+
255
+ - **Claude-family models** (any resolved id whose provider is
256
+ `anthropic/`, or whose segment after the provider prefix contains
257
+ `claude-`, which covers `anthropic/claude-...` as well as a Claude model
258
+ fronted by a different provider, e.g. `github-copilot/claude-sonnet-4.6`
259
+ or the nested `openrouter/anthropic/claude-opus-4.8`): only `high` and
260
+ `xhigh` get an effort field, as `variant: high` and `variant: max`
261
+ respectively; `low` and `medium` collapse to no effort field at all,
262
+ since opencode's `variant:` option does not distinguish an effort below
263
+ `high`. This collapse is deliberate and documented, not a bug: a
264
+ `low`/`medium` variant on a Claude-family model still gets its class's
265
+ `model:` line, just no `variant:` line.
266
+ - **Ollama, or an id with no provider prefix**: no effort field at all.
267
+ There is no known effort passthrough for Ollama, and an id with no `/`
268
+ resolves to no provider to key the decision on.
269
+ - **Every other non-Claude-family model**: a plain `reasoningEffort: <tier>`
270
+ line, `xhigh` included (opencode's built-in OpenAI-style variants
271
+ document an `xhigh` reasoning effort).
272
+
273
+ The variant's `model:` line is resolved the same way the base per-role model
274
+ is (an `opencode models` catalog lookup against the auto-detected or
275
+ `--opencode-provider`-specified provider), just keyed by the tier's model
276
+ class instead of by role. When that lookup cannot resolve a model for a
277
+ class, the CLI warns once on stderr and **no variant file is rendered for
278
+ that class at all**, not a file with a missing `model:` line: a variant
279
+ with no resolved model would carry neither a `model:` nor an effort line, an
280
+ indistinguishable no-op duplicate of the base file with no ledger entry to
281
+ compare it against, so `init` skips writing it entirely. This guard and its
282
+ warning are opencode-scoped only; Claude Code variants resolve `model:` from
283
+ a plain alias (`haiku`/`sonnet`/`opus`) and need no live catalog lookup, so
284
+ they are unaffected.
285
+
286
+ **Warning: `CLAUDE_CODE_EFFORT_LEVEL` overrides every agent's frontmatter
287
+ `effort:`, tier variants included.** Claude Code's `effort:` frontmatter
288
+ field does work: it reaches the model request as `output_config.effort`.
289
+ But when the harness environment sets `CLAUDE_CODE_EFFORT_LEVEL`, that
290
+ environment variable wins over the frontmatter `effort:` on every installed
291
+ agent, tier variants and default files alike, not just the one this feature
292
+ adds. Check for it before relying on a specific tier variant's requested
293
+ effort actually taking effect.
294
+
198
295
  ## Ownership and re-runs
199
296
 
200
297
  `init` is idempotent: a second run changes nothing. The rules:
@@ -208,8 +305,9 @@ be supplied as a fully-qualified `--models` entry, e.g.
208
305
  updates files you never touched and reports files you edited as conflicts
209
306
  instead of overwriting them; `--force` overwrites those too.
210
307
  - `.ai/workflow/manifest.json` is the kit's state file. It records the applied
211
- version, harnesses, role profile, models, and file hashes, and is rewritten
212
- whenever that state changes; do not edit it by hand.
308
+ version, harnesses, role profile, models, the `--tiers` flag, and file
309
+ hashes, and is rewritten whenever that state changes; do not edit it by
310
+ hand.
213
311
 
214
312
  ## Uninstall
215
313
 
package/dist/cli.js CHANGED
@@ -5,8 +5,8 @@ import { Command } from "commander";
5
5
  import inquirer from "inquirer";
6
6
  import { PACKAGE_VERSION } from "./assets.js";
7
7
  import { HARNESSES, detectHarnesses, parseHarnessList } from "./detect.js";
8
- import { DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, PROFILES, assertValidModelId, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
9
- import { loadOpencodeCatalog, resolveOpencodeModels } from "./opencode.js";
8
+ import { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, MODEL_CLASSES, PROFILES, assertValidModelId, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
9
+ import { detectProvider, loadOpencodeCatalog, resolveAlias, resolveOpencodeModels, } from "./opencode.js";
10
10
  import { readInstalledManifest, runInit } from "./init.js";
11
11
  import { runUninstall } from "./uninstall.js";
12
12
  function isInteractive() {
@@ -125,6 +125,8 @@ program
125
125
  .option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
126
126
  .option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: full, or the previously installed profile on a re-run`)
127
127
  .option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
128
+ .option("--tiers", "also render per-role effort-tier subagent variants (<role>-<tier>.md); default: off, or the previously installed value on a re-run")
129
+ .option("--no-tiers", "explicitly turn effort-tier subagent variants off, overriding a previously installed --tiers value")
128
130
  .action(async (dir, opts) => {
129
131
  const targetDir = requireDirectory(dir);
130
132
  if (!targetDir)
@@ -148,7 +150,7 @@ program
148
150
  const installedFor = previous.harnesses.length > 0
149
151
  ? previous.harnesses.join(", ")
150
152
  : "none recorded";
151
- console.log(`Found existing install (${version.startsWith("unknown") ? version : `v${version}`}, harnesses: ${installedFor}, profile: ${previous.profile})`);
153
+ console.log(`Found existing install (${version.startsWith("unknown") ? version : `v${version}`}, harnesses: ${installedFor}, profile: ${previous.profile}, tiers: ${previous.tiers})`);
152
154
  }
153
155
  let harnesses;
154
156
  if (opts.harness) {
@@ -184,10 +186,22 @@ program
184
186
  models = parseModelsSpec(opts.models, models);
185
187
  if (interactive && !opts.models)
186
188
  models = await promptModels(models, rolesForProfile(profile));
189
+ // Explicit --tiers/--no-tiers always override; a plain re-run (neither
190
+ // flag passed) keeps whatever the previous install had (default false
191
+ // for a fresh install), same override-vs-persist rule as
192
+ // --profile/--models above. commander's negatable-option pairing
193
+ // (--tiers / --no-tiers declared under the same "tiers" option name)
194
+ // resolves opts.tiers to `true` when --tiers is passed, `false` when
195
+ // --no-tiers is passed, and `undefined` when neither is passed; the
196
+ // CLI re-run test below verifies this against the installed commander
197
+ // version rather than assuming it. No interactive prompt: tiers is
198
+ // opt-in/off via the flags only.
199
+ const tiers = opts.tiers ?? previous?.tiers ?? false;
187
200
  // Resolve opencode model aliases against the live catalog when the opencode
188
201
  // harness is selected. The shell-out stays here in the CLI so runInit
189
202
  // remains pure.
190
203
  let opencodeModels;
204
+ let opencodeClassModels;
191
205
  if (harnesses.includes("opencode")) {
192
206
  const catalog = loadOpencodeCatalog();
193
207
  const { resolved, warnings } = resolveOpencodeModels(models, {
@@ -198,6 +212,37 @@ program
198
212
  for (const w of warnings) {
199
213
  process.stderr.write(`Warning: ${w}\n`);
200
214
  }
215
+ if (tiers) {
216
+ const providerResult = detectProvider({
217
+ catalog,
218
+ explicit: opts.opencodeProvider,
219
+ });
220
+ opencodeClassModels = {};
221
+ for (const modelClass of MODEL_CLASSES) {
222
+ const alias = CLASS_MODELS[modelClass];
223
+ const resolved = providerResult.provider
224
+ ? resolveAlias(providerResult.provider, alias, catalog)
225
+ : undefined;
226
+ opencodeClassModels[modelClass] = resolved;
227
+ if (resolved !== undefined)
228
+ continue;
229
+ // One warning per unresolved model class: without it, every
230
+ // effort-tier variant keyed to this class is silently skipped
231
+ // (init.ts skips the variant write entirely when the class
232
+ // model is unresolved), with nothing on stderr saying why.
233
+ const reason = providerResult.provider
234
+ ? `provider "${providerResult.provider}" has no "${alias}" model in the catalog`
235
+ : providerResult.ambiguous
236
+ ? `multiple providers offer Claude models in the live catalog; cannot auto-detect`
237
+ : `no provider offering Claude models found in the live catalog`;
238
+ // States the real effect (no variant file at all, not just a
239
+ // missing model: line, since init.ts skips the write entirely
240
+ // when the class never resolves) and the real scope (opencode
241
+ // only: Claude Code variants resolve model: from a plain alias
242
+ // and need no live catalog lookup, so they are unaffected).
243
+ process.stderr.write(`Warning: Tier model class "${modelClass}" (alias "${alias}") could not be resolved to an opencode model id (${reason}); no opencode effort-tier variant files will be rendered for this class (Claude Code variants are unaffected).\n`);
244
+ }
245
+ }
201
246
  }
202
247
  const report = runInit({
203
248
  targetDir,
@@ -206,6 +251,8 @@ program
206
251
  profile,
207
252
  force: opts.force,
208
253
  opencodeModels,
254
+ tiers,
255
+ opencodeClassModels,
209
256
  });
210
257
  showPaths("Created", report.written);
211
258
  showPaths("Updated", report.updated);
@@ -213,7 +260,7 @@ program
213
260
  showPaths("Conflicts (local edits kept, re-run with --force to overwrite)", report.conflicted);
214
261
  for (const note of report.notes)
215
262
  console.log(note);
216
- console.log(`\norchestrator-workflow v${PACKAGE_VERSION} installed for: ${harnesses.join(", ")} (profile: ${profile})`);
263
+ console.log(`\norchestrator-workflow v${PACKAGE_VERSION} installed for: ${harnesses.join(", ")} (profile: ${profile}, tiers: ${tiers})`);
217
264
  });
218
265
  program
219
266
  .command("uninstall")
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { runUninstall } from "./uninstall.js";
4
4
  export type { UninstallReport } from "./uninstall.js";
5
5
  export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
6
6
  export type { Harness } from "./detect.js";
7
- export { DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, PROFILES, ROLES, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
8
- export type { ModelAlias, Profile, Role } from "./models.js";
7
+ export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
8
+ export type { ModelAlias, ModelClass, Profile, Role, Tier } from "./models.js";
9
9
  export type { Report } from "./writers.js";
10
10
  export { PACKAGE_VERSION } from "./assets.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { runInit } from "./init.js";
2
2
  export { runUninstall } from "./uninstall.js";
3
3
  export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
4
- export { DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, PROFILES, ROLES, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
4
+ export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
5
5
  export { PACKAGE_VERSION } from "./assets.js";
package/dist/init.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Harness } from "./detect.js";
2
- import type { Profile, Role } from "./models.js";
2
+ import type { ModelClass, Profile, Role } from "./models.js";
3
3
  import type { Report } from "./writers.js";
4
4
  export interface InitOptions {
5
5
  targetDir: string;
@@ -20,6 +20,22 @@ export interface InitOptions {
20
20
  * aliases, producing the same inherit-session-model behaviour for bare inputs.
21
21
  */
22
22
  opencodeModels?: Record<Role, string | undefined>;
23
+ /**
24
+ * Renders additional per-role effort-tier subagent variants
25
+ * (`<role>-<tier>.md`) alongside the default agent file. Defaults to
26
+ * `false` (today's unconditional behavior: only the default file), so
27
+ * existing callers that do not pass this field see no change.
28
+ */
29
+ tiers?: boolean;
30
+ /**
31
+ * Resolved fully-qualified opencode model ids per tier's model class
32
+ * (`small`/`medium`/`large`), or `undefined` to omit the `model:` line for
33
+ * that variant. Only consulted when `tiers` is true and the `opencode`
34
+ * harness is selected; mirrors `opencodeModels` but keyed by model class
35
+ * instead of role, since a tier variant's model is chosen by class, not
36
+ * by the role's own preselected model.
37
+ */
38
+ opencodeClassModels?: Record<ModelClass, string | undefined>;
23
39
  }
24
40
  export interface Manifest {
25
41
  kit: string;
@@ -28,6 +44,8 @@ export interface Manifest {
28
44
  models: Record<Role, string>;
29
45
  /** Which subagent roles were installed: `"minimal"` or `"full"`. */
30
46
  profile: Profile;
47
+ /** Whether per-role effort-tier subagent variants were rendered. */
48
+ tiers: boolean;
31
49
  /**
32
50
  * sha256 of every kit-owned file as installed. This is how a re-run tells
33
51
  * "upstream changed, safe to update" apart from "user edited, conflict".
package/dist/init.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { isAbsolute, join, normalize, sep } from "node:path";
4
4
  import { PACKAGE_VERSION, listTemplateNames, readAgentAsset, readAsset, } from "./assets.js";
5
5
  import { HARNESSES } from "./detect.js";
6
- import { DEFAULT_PROFILE, READ_ONLY_ROLES, ROLES, assertValidModelId, claudeModelValue, isProfile, opencodeModelValue, rolesForProfile, } from "./models.js";
6
+ import { CLASS_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, READ_ONLY_ROLES, ROLES, ROLE_TIERS, TIER_DEFS, assertValidModelId, claudeModelValue, isProfile, opencodeModelValue, rolesForProfile, } from "./models.js";
7
7
  import { emptyReport, ensureClaudeImport, installFile, upsertMarkerSection, } from "./writers.js";
8
8
  const SKILL_NAME = "orchestrator-workflow";
9
9
  const MANIFEST_PATH = join(".ai", "workflow", "manifest.json");
@@ -76,12 +76,18 @@ export function readInstalledManifest(targetDir) {
76
76
  const profile = typeof candidate.profile === "string" && isProfile(candidate.profile)
77
77
  ? candidate.profile
78
78
  : DEFAULT_PROFILE;
79
+ // A manifest written before tiers existed carries no `tiers` field; that
80
+ // install never rendered variant files, so it degrades to `false` here
81
+ // (the same per-field-degradation style as `profile` above) rather than
82
+ // throwing on a legacy manifest.
83
+ const tiers = typeof candidate.tiers === "boolean" ? candidate.tiers : false;
79
84
  return {
80
85
  kit: SKILL_NAME,
81
86
  version: typeof candidate.version === "string" ? candidate.version : "",
82
87
  harnesses,
83
88
  models: models,
84
89
  profile,
90
+ tiers,
85
91
  files,
86
92
  installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : "",
87
93
  };
@@ -122,6 +128,98 @@ function composeOpencodeAgent(role, modelValue) {
122
128
  frontmatter.push("---");
123
129
  return [...frontmatter, "", asset.body.trimEnd(), ""].join("\n");
124
130
  }
131
+ function tierDescriptionSuffix(asset, tier) {
132
+ return `${asset.description} (Effort tier: ${tier}.)`;
133
+ }
134
+ /**
135
+ * Composes a tier-variant sibling of `composeClaudeAgent` (`<role>-<tier>.md`).
136
+ * The default-tier file is never rendered here (callers skip it via
137
+ * `DEFAULT_TIER`), so `composeClaudeAgent`'s own output stays byte-identical
138
+ * whether or not tiers are on.
139
+ */
140
+ function composeClaudeAgentVariant(role, tier) {
141
+ const asset = readAgentAsset(role);
142
+ const def = TIER_DEFS[tier];
143
+ const frontmatter = [
144
+ "---",
145
+ `name: ${asset.name}-${tier}`,
146
+ `description: ${yamlQuote(tierDescriptionSuffix(asset, tier))}`,
147
+ `model: ${claudeModelValue(CLASS_MODELS[def.modelClass])}`,
148
+ `effort: ${def.effort}`,
149
+ ];
150
+ if (READ_ONLY_ROLES.has(role)) {
151
+ frontmatter.push("disallowedTools: Edit, Write, NotebookEdit");
152
+ }
153
+ frontmatter.push("---");
154
+ return [...frontmatter, "", asset.body.trimEnd(), ""].join("\n");
155
+ }
156
+ /**
157
+ * Whether a resolved opencode model id belongs to the Claude family,
158
+ * regardless of which provider is fronting it (`anthropic/claude-...`,
159
+ * `github-copilot/claude-...`, `openrouter/anthropic/claude-...`, ...): the
160
+ * segment after the provider prefix contains `claude-`, or the id starts
161
+ * with `anthropic/` outright. Dispatch on family rather than provider id
162
+ * because the `variant:` effort surface is a property of the model being
163
+ * served, not of which provider happens to front it.
164
+ */
165
+ function isClaudeFamilyModel(modelValue) {
166
+ if (modelValue.startsWith("anthropic/"))
167
+ return true;
168
+ const slash = modelValue.indexOf("/");
169
+ const remainder = slash === -1 ? "" : modelValue.slice(slash + 1);
170
+ return remainder.includes("claude-");
171
+ }
172
+ /**
173
+ * The opencode effort surface is keyed by model family, not provider id:
174
+ * Claude-family models' `variant:` option only distinguishes `high` and
175
+ * `max` (mapped from the `high`/`xhigh` tiers; `low`/`medium` collapse to no
176
+ * effort field), Ollama and ids without a provider prefix have no known
177
+ * effort passthrough, and every other model accepts a plain
178
+ * `reasoningEffort:` value. An unresolved (`undefined`) model gets no
179
+ * effort field either, since there is then no model to key the decision on.
180
+ */
181
+ function opencodeVariantEffortLine(tier, modelValue) {
182
+ if (!modelValue)
183
+ return undefined;
184
+ if (isClaudeFamilyModel(modelValue)) {
185
+ if (tier === "high")
186
+ return "variant: high";
187
+ if (tier === "xhigh")
188
+ return "variant: max";
189
+ return undefined;
190
+ }
191
+ const slash = modelValue.indexOf("/");
192
+ const provider = slash === -1 ? undefined : modelValue.slice(0, slash);
193
+ if (provider === undefined || provider === "ollama")
194
+ return undefined;
195
+ return `reasoningEffort: ${TIER_DEFS[tier].effort}`;
196
+ }
197
+ /**
198
+ * Composes a tier-variant sibling of `composeOpencodeAgent`. `effortLine` is
199
+ * decided once, at the single call site in the opencode tier loop of
200
+ * `runInit` (via `opencodeVariantEffortLine`), and passed in rather than
201
+ * recomputed here, so there is exactly one place that decides it instead
202
+ * of a second, independent source of truth for the same value.
203
+ */
204
+ function composeOpencodeAgentVariant(role, tier, modelValue, effortLine) {
205
+ const asset = readAgentAsset(role);
206
+ const frontmatter = [
207
+ "---",
208
+ `description: ${yamlQuote(tierDescriptionSuffix(asset, tier))}`,
209
+ "mode: subagent",
210
+ ];
211
+ if (modelValue) {
212
+ frontmatter.push(`model: ${modelValue}`);
213
+ }
214
+ if (effortLine) {
215
+ frontmatter.push(effortLine);
216
+ }
217
+ if (READ_ONLY_ROLES.has(role)) {
218
+ frontmatter.push("permission:", " edit: deny");
219
+ }
220
+ frontmatter.push("---");
221
+ return [...frontmatter, "", asset.body.trimEnd(), ""].join("\n");
222
+ }
125
223
  export function runInit(options) {
126
224
  const { targetDir } = options;
127
225
  if (!existsSync(targetDir)) {
@@ -132,9 +230,25 @@ export function runInit(options) {
132
230
  }
133
231
  const force = options.force ?? false;
134
232
  const profile = options.profile ?? DEFAULT_PROFILE;
233
+ const tiers = options.tiers ?? false;
135
234
  const report = emptyReport();
136
235
  const previous = readInstalledManifest(targetDir);
137
236
  const installedFiles = {};
237
+ // Both leftover-note loops below are ledger-driven, not enumeration-
238
+ // driven: they only ever push a note for a relative path that the
239
+ // *previous* install actually recorded in `previous.files`, and only ever
240
+ // iterate `previous.harnesses` (the harnesses that install actually wrote
241
+ // files for), never `ROLE_TIERS`/`options.harnesses` as a source of truth.
242
+ // Deriving the note set from ROLE_TIERS/options.harnesses instead would
243
+ // (a) claim a leftover for a variant file that was never written in the
244
+ // first place (e.g. an opencode install whose tier-class models never
245
+ // resolved, so the unresolved-class guard skipped every variant write),
246
+ // and (b) miss a real leftover whose harness was dropped from
247
+ // options.harnesses this run, since that harness's files are still
248
+ // sitting on disk and still becoming untracked either way. The ledger
249
+ // (`previous.files`/`previous.harnesses`) is the only source of truth for
250
+ // "what did the previous install actually put on disk."
251
+ const previousHarnessDirs = (previous?.harnesses ?? []).filter((harness) => harness === "claude" || harness === "opencode");
138
252
  // A full -> minimal downgrade drops explorer/task-slicer from the roles
139
253
  // installed, but (like dropping a harness from --harness) existing role
140
254
  // files are never deleted: they simply fall out of the manifest's file
@@ -142,12 +256,46 @@ export function runInit(options) {
142
256
  // left as an unexplained, untracked leftover on disk.
143
257
  if (previous && previous.profile === "full" && profile !== previous.profile) {
144
258
  const droppedRoles = rolesForProfile(previous.profile).filter((role) => !rolesForProfile(profile).includes(role));
145
- const harnessDirs = options.harnesses.filter((harness) => harness === "claude" || harness === "opencode");
146
- for (const harness of harnessDirs) {
147
- const harnessDir = harness === "claude" ? ".claude" : ".opencode";
259
+ for (const harnessDir of previousHarnessDirs.map((harness) => harness === "claude" ? ".claude" : ".opencode")) {
148
260
  for (const role of droppedRoles) {
149
261
  const relativePath = join(harnessDir, "agents", `${role}.md`);
150
- report.notes.push(`${relativePath}: now untracked after the full -> ${profile} profile downgrade; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
262
+ if (previous.files[relativePath] !== undefined) {
263
+ report.notes.push(`${relativePath}: now untracked after the full -> ${profile} profile downgrade; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
264
+ }
265
+ // A dropped role that also had tiers on previously left behind its
266
+ // own <role>-<tier>.md variant files, not just its base file; the
267
+ // note above only knows about <role>.md, so those variants would go
268
+ // unmentioned even though they are equally untracked now. ROLE_TIERS
269
+ // only supplies the candidate tier suffixes to probe; the ledger
270
+ // check above/below is what decides whether a note is actually due.
271
+ for (const tier of ROLE_TIERS[role]) {
272
+ if (tier === DEFAULT_TIER[role])
273
+ continue;
274
+ const variantPath = join(harnessDir, "agents", `${role}-${tier}.md`);
275
+ if (previous.files[variantPath] !== undefined) {
276
+ report.notes.push(`${variantPath}: now untracked after the full -> ${profile} profile downgrade; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+ // A tiers on -> off transition leaves each still-installed role's
283
+ // <role>-<tier>.md variant files behind on disk, the same untracked-
284
+ // leftover shape as the full -> minimal profile downgrade above (a role
285
+ // dropped from the profile is handled by the block above instead, so
286
+ // there is no overlap between the two loops). Surface it the same way:
287
+ // a note per file instead of a silent, unexplained leftover.
288
+ if (previous && previous.tiers && !tiers) {
289
+ for (const harnessDir of previousHarnessDirs.map((harness) => harness === "claude" ? ".claude" : ".opencode")) {
290
+ for (const role of rolesForProfile(profile)) {
291
+ for (const tier of ROLE_TIERS[role]) {
292
+ if (tier === DEFAULT_TIER[role])
293
+ continue;
294
+ const relativePath = join(harnessDir, "agents", `${role}-${tier}.md`);
295
+ if (previous.files[relativePath] !== undefined) {
296
+ report.notes.push(`${relativePath}: now untracked after tiers were turned off; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
297
+ }
298
+ }
151
299
  }
152
300
  }
153
301
  }
@@ -189,6 +337,13 @@ export function runInit(options) {
189
337
  installKitFile(join(".claude", "skills", SKILL_NAME, "SKILL.md"), skill);
190
338
  for (const role of rolesForProfile(profile)) {
191
339
  installKitFile(join(".claude", "agents", `${role}.md`), composeClaudeAgent(role, options.models[role]));
340
+ if (tiers) {
341
+ for (const tier of ROLE_TIERS[role]) {
342
+ if (tier === DEFAULT_TIER[role])
343
+ continue;
344
+ installKitFile(join(".claude", "agents", `${role}-${tier}.md`), composeClaudeAgentVariant(role, tier));
345
+ }
346
+ }
192
347
  }
193
348
  ensureClaudeImport(report, join(targetDir, "CLAUDE.md"));
194
349
  }
@@ -202,6 +357,31 @@ export function runInit(options) {
202
357
  ? options.opencodeModels[role]
203
358
  : opencodeModelValue(options.models[role]);
204
359
  installKitFile(join(".opencode", "agents", `${role}.md`), composeOpencodeAgent(role, modelValue));
360
+ if (tiers) {
361
+ for (const tier of ROLE_TIERS[role]) {
362
+ if (tier === DEFAULT_TIER[role])
363
+ continue;
364
+ const modelClass = TIER_DEFS[tier].modelClass;
365
+ const variantModelValue = options.opencodeClassModels?.[modelClass];
366
+ if (variantModelValue === undefined) {
367
+ // No model resolved for this class: opencodeVariantEffortLine
368
+ // always returns undefined too when its modelValue argument is
369
+ // undefined (it short-circuits on that first), so this variant
370
+ // would carry neither a model: nor an effort line, a silent
371
+ // no-op duplicate of the base file's own (possibly also
372
+ // unresolved) model line, with no ledger entry to compare it
373
+ // against. Skip it entirely rather than write that
374
+ // indistinguishable file. A *resolved* model with no effort line
375
+ // (e.g. a low/medium tier on a Claude-family model, or any
376
+ // Ollama model) is NOT skipped by this check: it still renders
377
+ // with just its model: line, see the effort-field dispatch
378
+ // rules in opencodeVariantEffortLine above.
379
+ continue;
380
+ }
381
+ const effortLine = opencodeVariantEffortLine(tier, variantModelValue);
382
+ installKitFile(join(".opencode", "agents", `${role}-${tier}.md`), composeOpencodeAgentVariant(role, tier, variantModelValue, effortLine));
383
+ }
384
+ }
205
385
  }
206
386
  }
207
387
  // The manifest records applied state, so it is written last and only when
@@ -212,6 +392,7 @@ export function runInit(options) {
212
392
  harnesses: [...options.harnesses].sort(),
213
393
  models: options.models,
214
394
  profile,
395
+ tiers,
215
396
  files: installedFiles,
216
397
  };
217
398
  const manifestPath = join(targetDir, MANIFEST_PATH);
@@ -222,6 +403,7 @@ export function runInit(options) {
222
403
  harnesses: previous.harnesses,
223
404
  models: previous.models,
224
405
  profile: previous.profile,
406
+ tiers: previous.tiers,
225
407
  files: previous.files,
226
408
  }) === JSON.stringify(desired)) {
227
409
  report.skipped.push(manifestPath);
package/dist/models.d.ts CHANGED
@@ -54,3 +54,32 @@ export declare function assertValidModelId(model: string): void;
54
54
  * the given base mapping. Unknown roles and empty values are rejected.
55
55
  */
56
56
  export declare function parseModelsSpec(spec: string, base: Record<Role, string>): Record<Role, string>;
57
+ /**
58
+ * Effort tiers: additional per-role subagent variants rendered alongside the
59
+ * default (unsuffixed) agent file when the `tiers` feature is on. Default
60
+ * off; see `install-fence-mechanics.md` for the default-off pack rationale.
61
+ */
62
+ export type Tier = "low" | "medium" | "high" | "xhigh";
63
+ /**
64
+ * Which tiers each role gets a variant file for. A tier outside a role's
65
+ * list is never rendered for that role (e.g. explorer/task-slicer never get
66
+ * an `xhigh` variant, reviewer never gets a `low` variant).
67
+ */
68
+ export declare const ROLE_TIERS: Record<Role, Tier[]>;
69
+ /**
70
+ * The tier each role's default (unsuffixed) agent file already corresponds
71
+ * to. No variant file is ever rendered for this tier: rendering one would
72
+ * both collide with the default file's name and duplicate it.
73
+ */
74
+ export declare const DEFAULT_TIER: Record<Role, Tier>;
75
+ export type ModelClass = "small" | "medium" | "large";
76
+ export declare const MODEL_CLASSES: ModelClass[];
77
+ interface TierDef {
78
+ modelClass: ModelClass;
79
+ /** Effort value requested from the harness for this tier. */
80
+ effort: Tier;
81
+ }
82
+ export declare const TIER_DEFS: Record<Tier, TierDef>;
83
+ /** Which model alias backs each tier's model class. */
84
+ export declare const CLASS_MODELS: Record<ModelClass, ModelAlias>;
85
+ export {};
package/dist/models.js CHANGED
@@ -105,3 +105,38 @@ export function parseModelsSpec(spec, base) {
105
105
  }
106
106
  return result;
107
107
  }
108
+ /**
109
+ * Which tiers each role gets a variant file for. A tier outside a role's
110
+ * list is never rendered for that role (e.g. explorer/task-slicer never get
111
+ * an `xhigh` variant, reviewer never gets a `low` variant).
112
+ */
113
+ export const ROLE_TIERS = {
114
+ explorer: ["low", "medium", "high"],
115
+ "task-slicer": ["low", "medium", "high"],
116
+ implementer: ["low", "medium", "high", "xhigh"],
117
+ reviewer: ["medium", "high", "xhigh"],
118
+ };
119
+ /**
120
+ * The tier each role's default (unsuffixed) agent file already corresponds
121
+ * to. No variant file is ever rendered for this tier: rendering one would
122
+ * both collide with the default file's name and duplicate it.
123
+ */
124
+ export const DEFAULT_TIER = {
125
+ explorer: "medium",
126
+ "task-slicer": "medium",
127
+ implementer: "medium",
128
+ reviewer: "high",
129
+ };
130
+ export const MODEL_CLASSES = ["small", "medium", "large"];
131
+ export const TIER_DEFS = {
132
+ low: { modelClass: "small", effort: "low" },
133
+ medium: { modelClass: "medium", effort: "medium" },
134
+ high: { modelClass: "medium", effort: "high" },
135
+ xhigh: { modelClass: "large", effort: "xhigh" },
136
+ };
137
+ /** Which model alias backs each tier's model class. */
138
+ export const CLASS_MODELS = {
139
+ small: "haiku",
140
+ medium: "sonnet",
141
+ large: "opus",
142
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrator-workflow",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",