orchestrator-workflow 0.1.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.
@@ -0,0 +1,33 @@
1
+ # Implementation Summary
2
+
3
+ ## Status
4
+
5
+ not_started | in_progress | done | partial | blocked
6
+
7
+ ## Completed Tasks
8
+
9
+ - <!-- T-001 -->
10
+
11
+ ## Changed Files
12
+
13
+ | File | Reason |
14
+ |---|---|
15
+ | <!-- path --> | <!-- reason --> |
16
+
17
+ ## Test Evidence
18
+
19
+ ### Executed
20
+
21
+ - <!-- command/result -->
22
+
23
+ ### Added or Updated
24
+
25
+ - <!-- test file -->
26
+
27
+ ### Not Executed
28
+
29
+ <!-- Explain why, if applicable. -->
30
+
31
+ ## Risks / Notes
32
+
33
+ - <!-- note -->
@@ -0,0 +1,23 @@
1
+ # Review Findings
2
+
3
+ ## Review Summary
4
+
5
+ <!-- Short summary. -->
6
+
7
+ ## Findings
8
+
9
+ | Severity | Category | Description | Suggested Fix | Decision |
10
+ |---|---|---|---|---|
11
+ | low/medium/high/critical | correctness/architecture/security/tests/maintainability/performance/docs | <!-- finding --> | <!-- fix --> | accepted/fix/defer/reject |
12
+
13
+ ## Missing Tests
14
+
15
+ - <!-- missing test -->
16
+
17
+ ## Residual Risks
18
+
19
+ - <!-- risk -->
20
+
21
+ ## Acceptance Recommendation
22
+
23
+ accept | accept_with_notes | fix_required | reject
@@ -0,0 +1,25 @@
1
+ # Operator Handoff
2
+
3
+ ## Summary
4
+
5
+ <!-- What changed? -->
6
+
7
+ ## Why
8
+
9
+ <!-- Why was this approach chosen? -->
10
+
11
+ ## Verification
12
+
13
+ - <!-- tests/checks performed -->
14
+
15
+ ## Known Risks
16
+
17
+ - <!-- risk or none -->
18
+
19
+ ## Follow-Ups
20
+
21
+ - <!-- next steps or none -->
22
+
23
+ ## Final Status
24
+
25
+ accepted | accepted_with_notes | needs_followup | blocked
@@ -0,0 +1,17 @@
1
+ import type { Role } from "./models.js";
2
+ /** Resolves from both src/ (tsx dev) and dist/ (built) to the package root. */
3
+ export declare const ASSETS_DIR: string;
4
+ export declare const PACKAGE_VERSION: string;
5
+ export declare function readAsset(relativePath: string): string;
6
+ export declare function listTemplateNames(): string[];
7
+ export interface AgentAsset {
8
+ name: string;
9
+ description: string;
10
+ body: string;
11
+ }
12
+ /**
13
+ * The agent assets are the single source of truth for the role prompts. They
14
+ * carry a minimal `name` + `description` frontmatter; the harness-specific
15
+ * frontmatter (model, mode) is composed at install time.
16
+ */
17
+ export declare function readAgentAsset(role: Role): AgentAsset;
package/dist/assets.js ADDED
@@ -0,0 +1,34 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** Resolves from both src/ (tsx dev) and dist/ (built) to the package root. */
5
+ export const ASSETS_DIR = fileURLToPath(new URL("../assets/", import.meta.url));
6
+ export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
7
+ export function readAsset(relativePath) {
8
+ return readFileSync(join(ASSETS_DIR, relativePath), "utf8");
9
+ }
10
+ export function listTemplateNames() {
11
+ return readdirSync(join(ASSETS_DIR, "templates"))
12
+ .filter((name) => name.endsWith(".md"))
13
+ .sort();
14
+ }
15
+ /**
16
+ * The agent assets are the single source of truth for the role prompts. They
17
+ * carry a minimal `name` + `description` frontmatter; the harness-specific
18
+ * frontmatter (model, mode) is composed at install time.
19
+ */
20
+ export function readAgentAsset(role) {
21
+ const raw = readAsset(join("agents", `${role}.md`));
22
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n+([\s\S]*)$/);
23
+ if (!match) {
24
+ throw new Error(`Agent asset for "${role}" has no frontmatter block`);
25
+ }
26
+ const [, frontmatter, body] = match;
27
+ const name = frontmatter.match(/^name: (.+)$/m)?.[1]?.trim();
28
+ const descriptionRaw = frontmatter.match(/^description: (.+)$/m)?.[1]?.trim();
29
+ if (!name || !descriptionRaw) {
30
+ throw new Error(`Agent asset for "${role}" is missing name or description`);
31
+ }
32
+ const description = descriptionRaw.replace(/^"(.*)"$/, "$1");
33
+ return { name, description, body };
34
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { Command } from "commander";
5
+ import inquirer from "inquirer";
6
+ import { PACKAGE_VERSION } from "./assets.js";
7
+ import { HARNESSES, detectHarnesses, parseHarnessList } from "./detect.js";
8
+ import { DEFAULT_MODELS, MODEL_ALIASES, ROLES, assertValidModelId, parseModelsSpec, } from "./models.js";
9
+ import { readInstalledManifest, runInit } from "./init.js";
10
+ function isInteractive() {
11
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
+ }
13
+ async function promptHarnesses(detected, installed) {
14
+ const known = [...new Set([...detected, ...installed])];
15
+ const preselected = known.length > 0 ? known : ["claude"];
16
+ const { harnesses } = await inquirer.prompt([
17
+ {
18
+ type: "checkbox",
19
+ name: "harnesses",
20
+ message: "Install adapters for which harnesses?",
21
+ choices: HARNESSES.map((harness) => ({
22
+ name: harness + (detected.includes(harness) ? " (detected)" : ""),
23
+ value: harness,
24
+ checked: preselected.includes(harness),
25
+ })),
26
+ validate: (selection) => selection.length > 0 || "Select at least one harness",
27
+ },
28
+ ]);
29
+ return harnesses;
30
+ }
31
+ async function promptModels(base) {
32
+ const models = { ...base };
33
+ for (const role of ROLES) {
34
+ const { choice } = await inquirer.prompt([
35
+ {
36
+ type: "list",
37
+ name: "choice",
38
+ message: `Model for the ${role} subagent:`,
39
+ default: models[role],
40
+ choices: [
41
+ ...MODEL_ALIASES.map((alias) => ({
42
+ name: alias === DEFAULT_MODELS[role] ? `${alias} (default)` : alias,
43
+ value: alias,
44
+ })),
45
+ { name: "custom model id", value: "__custom__" },
46
+ ],
47
+ },
48
+ ]);
49
+ if (choice === "__custom__") {
50
+ const { custom } = await inquirer.prompt([
51
+ {
52
+ type: "input",
53
+ name: "custom",
54
+ message: `Custom model id for ${role}:`,
55
+ validate: (value) => {
56
+ try {
57
+ assertValidModelId(value.trim());
58
+ return true;
59
+ }
60
+ catch (error) {
61
+ return error instanceof Error ? error.message : String(error);
62
+ }
63
+ },
64
+ },
65
+ ]);
66
+ models[role] = custom.trim();
67
+ }
68
+ else {
69
+ models[role] = choice;
70
+ }
71
+ }
72
+ return models;
73
+ }
74
+ const program = new Command();
75
+ program
76
+ .name("orchestrator-workflow")
77
+ .description("Install an orchestrator-led agent workflow into a repository: .ai/ run state, an AGENTS.md policy section, and per-harness subagent definitions")
78
+ .version(PACKAGE_VERSION);
79
+ program
80
+ .command("init")
81
+ .description("Install or refresh the workflow kit in a target repository")
82
+ .argument("[dir]", "target repository directory", ".")
83
+ .option("-y, --yes", "accept all defaults and skip prompts")
84
+ .option("-f, --force", "overwrite kit-owned files that have local edits")
85
+ .option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: detected`)
86
+ .option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
87
+ .action(async (dir, opts) => {
88
+ const targetDir = resolve(dir);
89
+ if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
90
+ console.error(`Target is not a directory: ${targetDir}`);
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ const interactive = !opts.yes && isInteractive();
95
+ const detected = detectHarnesses(targetDir);
96
+ console.log(detected.length > 0
97
+ ? `Detected harness configs: ${detected.join(", ")}`
98
+ : "No existing harness configs detected");
99
+ // A previous install is the baseline; re-runs refresh it instead of
100
+ // resetting harnesses and models to the shipped defaults.
101
+ const previous = readInstalledManifest(targetDir);
102
+ if (previous) {
103
+ const version = previous.version || "unknown version";
104
+ const installedFor = previous.harnesses.length > 0
105
+ ? previous.harnesses.join(", ")
106
+ : "none recorded";
107
+ console.log(`Found existing install (${version.startsWith("unknown") ? version : `v${version}`}, harnesses: ${installedFor})`);
108
+ }
109
+ let harnesses;
110
+ if (opts.harness) {
111
+ harnesses = parseHarnessList(opts.harness);
112
+ }
113
+ else {
114
+ const installed = previous?.harnesses ?? [];
115
+ const fallback = [...new Set([...detected, ...installed])];
116
+ harnesses = interactive
117
+ ? await promptHarnesses(detected, installed)
118
+ : fallback.length > 0
119
+ ? fallback
120
+ : ["claude"];
121
+ }
122
+ let models = {
123
+ ...DEFAULT_MODELS,
124
+ ...(previous?.models ?? {}),
125
+ };
126
+ if (opts.models)
127
+ models = parseModelsSpec(opts.models, models);
128
+ if (interactive && !opts.models)
129
+ models = await promptModels(models);
130
+ const report = runInit({
131
+ targetDir,
132
+ harnesses,
133
+ models,
134
+ force: opts.force,
135
+ });
136
+ const show = (label, paths) => {
137
+ if (paths.length === 0)
138
+ return;
139
+ console.log(`${label}:`);
140
+ for (const path of paths)
141
+ console.log(` ${path}`);
142
+ };
143
+ show("Created", report.written);
144
+ show("Updated", report.updated);
145
+ show("Unchanged", report.skipped);
146
+ show("Conflicts (local edits kept, re-run with --force to overwrite)", report.conflicted);
147
+ console.log(`\norchestrator-workflow v${PACKAGE_VERSION} installed for: ${harnesses.join(", ")}`);
148
+ });
149
+ program.parseAsync(process.argv).catch((error) => {
150
+ console.error(error instanceof Error ? error.message : error);
151
+ process.exitCode = 1;
152
+ });
@@ -0,0 +1,9 @@
1
+ export type Harness = "claude" | "codex" | "opencode";
2
+ export declare const HARNESSES: Harness[];
3
+ /**
4
+ * Best-effort detection of which harnesses a target repository already uses.
5
+ * AGENTS.md alone is deliberately not a signal: every supported harness can
6
+ * consume it, so it does not identify one.
7
+ */
8
+ export declare function detectHarnesses(dir: string): Harness[];
9
+ export declare function parseHarnessList(list: string): Harness[];
package/dist/detect.js ADDED
@@ -0,0 +1,39 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const HARNESSES = ["claude", "codex", "opencode"];
4
+ function anyExists(dir, names) {
5
+ return names.some((name) => existsSync(join(dir, name)));
6
+ }
7
+ /**
8
+ * Best-effort detection of which harnesses a target repository already uses.
9
+ * AGENTS.md alone is deliberately not a signal: every supported harness can
10
+ * consume it, so it does not identify one.
11
+ */
12
+ export function detectHarnesses(dir) {
13
+ const detected = [];
14
+ if (anyExists(dir, [".claude", "CLAUDE.md"]))
15
+ detected.push("claude");
16
+ if (anyExists(dir, [".agents", ".codex"]))
17
+ detected.push("codex");
18
+ if (anyExists(dir, [".opencode", "opencode.json", "opencode.jsonc"])) {
19
+ detected.push("opencode");
20
+ }
21
+ return detected;
22
+ }
23
+ export function parseHarnessList(list) {
24
+ const parsed = [];
25
+ for (const entry of list.split(",")) {
26
+ const name = entry.trim().toLowerCase();
27
+ if (name === "")
28
+ continue;
29
+ if (!HARNESSES.includes(name)) {
30
+ throw new Error(`Unknown harness "${name}"; valid values: ${HARNESSES.join(", ")}`);
31
+ }
32
+ if (!parsed.includes(name))
33
+ parsed.push(name);
34
+ }
35
+ if (parsed.length === 0) {
36
+ throw new Error("--harness was given but contained no harness names");
37
+ }
38
+ return parsed;
39
+ }
@@ -0,0 +1,8 @@
1
+ export { runInit } from "./init.js";
2
+ export type { InitOptions } from "./init.js";
3
+ export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
4
+ export type { Harness } from "./detect.js";
5
+ export { DEFAULT_MODELS, MODEL_ALIASES, ROLES, claudeModelValue, opencodeModelValue, parseModelsSpec, } from "./models.js";
6
+ export type { ModelAlias, Role } from "./models.js";
7
+ export type { Report } from "./writers.js";
8
+ export { PACKAGE_VERSION } from "./assets.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { runInit } from "./init.js";
2
+ export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
3
+ export { DEFAULT_MODELS, MODEL_ALIASES, ROLES, claudeModelValue, opencodeModelValue, parseModelsSpec, } from "./models.js";
4
+ export { PACKAGE_VERSION } from "./assets.js";
package/dist/init.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Harness } from "./detect.js";
2
+ import type { Role } from "./models.js";
3
+ import type { Report } from "./writers.js";
4
+ export interface InitOptions {
5
+ targetDir: string;
6
+ harnesses: Harness[];
7
+ models: Record<Role, string>;
8
+ force?: boolean;
9
+ }
10
+ export interface Manifest {
11
+ kit: string;
12
+ version: string;
13
+ harnesses: Harness[];
14
+ models: Record<Role, string>;
15
+ /**
16
+ * sha256 of every kit-owned file as installed. This is how a re-run tells
17
+ * "upstream changed, safe to update" apart from "user edited, conflict".
18
+ */
19
+ files: Record<string, string>;
20
+ installedAt: string;
21
+ }
22
+ /**
23
+ * Reads the manifest of a previous install, if any. Manifests can be written
24
+ * by hand (manual agent installs) or damaged, so every field is sanitized;
25
+ * anything invalid degrades to "no record" instead of crashing or leaking
26
+ * unvalidated values into generated frontmatter.
27
+ */
28
+ export declare function readInstalledManifest(targetDir: string): Manifest | undefined;
29
+ export declare function runInit(options: InitOptions): Report;
package/dist/init.js ADDED
@@ -0,0 +1,187 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { PACKAGE_VERSION, listTemplateNames, readAgentAsset, readAsset, } from "./assets.js";
5
+ import { HARNESSES } from "./detect.js";
6
+ import { ROLES, assertValidModelId, claudeModelValue, opencodeModelValue, } from "./models.js";
7
+ import { emptyReport, ensureClaudeImport, installFile, upsertMarkerSection, } from "./writers.js";
8
+ const SKILL_NAME = "orchestrator-workflow";
9
+ const MANIFEST_PATH = join(".ai", "workflow", "manifest.json");
10
+ function sha256(content) {
11
+ return createHash("sha256").update(content, "utf8").digest("hex");
12
+ }
13
+ /**
14
+ * Reads the manifest of a previous install, if any. Manifests can be written
15
+ * by hand (manual agent installs) or damaged, so every field is sanitized;
16
+ * anything invalid degrades to "no record" instead of crashing or leaking
17
+ * unvalidated values into generated frontmatter.
18
+ */
19
+ export function readInstalledManifest(targetDir) {
20
+ const path = join(targetDir, MANIFEST_PATH);
21
+ if (!existsSync(path))
22
+ return undefined;
23
+ let raw;
24
+ try {
25
+ raw = JSON.parse(readFileSync(path, "utf8"));
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ if (typeof raw !== "object" || raw === null)
31
+ return undefined;
32
+ const candidate = raw;
33
+ if (candidate.kit !== SKILL_NAME)
34
+ return undefined;
35
+ const harnesses = (Array.isArray(candidate.harnesses) ? candidate.harnesses : []).filter((value) => HARNESSES.includes(value));
36
+ const models = {};
37
+ if (typeof candidate.models === "object" && candidate.models !== null) {
38
+ for (const role of ROLES) {
39
+ const value = candidate.models[role];
40
+ if (typeof value !== "string")
41
+ continue;
42
+ try {
43
+ assertValidModelId(value);
44
+ models[role] = value;
45
+ }
46
+ catch {
47
+ // Invalid model ids are dropped; the role falls back to defaults.
48
+ }
49
+ }
50
+ }
51
+ const files = {};
52
+ if (typeof candidate.files === "object" && candidate.files !== null) {
53
+ for (const [key, value] of Object.entries(candidate.files)) {
54
+ if (typeof value === "string")
55
+ files[key] = value;
56
+ }
57
+ }
58
+ return {
59
+ kit: SKILL_NAME,
60
+ version: typeof candidate.version === "string" ? candidate.version : "",
61
+ harnesses,
62
+ models: models,
63
+ files,
64
+ installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : "",
65
+ };
66
+ }
67
+ function yamlQuote(value) {
68
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
69
+ }
70
+ function composeClaudeAgent(role, model) {
71
+ const asset = readAgentAsset(role);
72
+ return [
73
+ "---",
74
+ `name: ${asset.name}`,
75
+ `description: ${yamlQuote(asset.description)}`,
76
+ `model: ${claudeModelValue(model)}`,
77
+ "---",
78
+ "",
79
+ asset.body.trimEnd(),
80
+ "",
81
+ ].join("\n");
82
+ }
83
+ function composeOpencodeAgent(role, model) {
84
+ const asset = readAgentAsset(role);
85
+ return [
86
+ "---",
87
+ `description: ${yamlQuote(asset.description)}`,
88
+ "mode: subagent",
89
+ `model: ${opencodeModelValue(model)}`,
90
+ "---",
91
+ "",
92
+ asset.body.trimEnd(),
93
+ "",
94
+ ].join("\n");
95
+ }
96
+ export function runInit(options) {
97
+ const { targetDir } = options;
98
+ if (!existsSync(targetDir)) {
99
+ throw new Error(`Target directory does not exist: ${targetDir}`);
100
+ }
101
+ if (!statSync(targetDir).isDirectory()) {
102
+ throw new Error(`Target is not a directory: ${targetDir}`);
103
+ }
104
+ const force = options.force ?? false;
105
+ const report = emptyReport();
106
+ const previous = readInstalledManifest(targetDir);
107
+ const installedFiles = {};
108
+ /**
109
+ * Installs a kit-owned file. An unedited file (it still matches the hash
110
+ * recorded at install time) is updated in place when the kit content
111
+ * changed; a locally edited file is only overwritten with --force.
112
+ */
113
+ const installKitFile = (relativePath, content) => {
114
+ const path = join(targetDir, relativePath);
115
+ const recorded = previous?.files?.[relativePath];
116
+ if (existsSync(path)) {
117
+ const existing = readFileSync(path, "utf8");
118
+ const unedited = recorded !== undefined && sha256(existing) === recorded;
119
+ installFile(report, path, content, { force: force || unedited });
120
+ if (readFileSync(path, "utf8") === content) {
121
+ installedFiles[relativePath] = sha256(content);
122
+ }
123
+ else if (recorded !== undefined) {
124
+ // Conflicted: the user's edit stays, and so does the original record.
125
+ installedFiles[relativePath] = recorded;
126
+ }
127
+ return;
128
+ }
129
+ installFile(report, path, content, { force });
130
+ installedFiles[relativePath] = sha256(content);
131
+ };
132
+ for (const name of listTemplateNames()) {
133
+ installKitFile(join(".ai", "workflow", "templates", name), readAsset(join("templates", name)));
134
+ }
135
+ installKitFile(join(".ai", "runs", ".gitkeep"), "");
136
+ // Codex and opencode read AGENTS.md natively; Claude Code gets it via the
137
+ // CLAUDE.md import. The policy section is therefore installed regardless of
138
+ // the harness selection. AGENTS.md and CLAUDE.md are user-owned: only the
139
+ // fenced section and the import line are ever touched.
140
+ upsertMarkerSection(report, join(targetDir, "AGENTS.md"), readAsset("agents-md-section.md"));
141
+ const skill = readAsset(join("skill", "SKILL.md"));
142
+ if (options.harnesses.includes("claude")) {
143
+ installKitFile(join(".claude", "skills", SKILL_NAME, "SKILL.md"), skill);
144
+ for (const role of ROLES) {
145
+ installKitFile(join(".claude", "agents", `${role}.md`), composeClaudeAgent(role, options.models[role]));
146
+ }
147
+ ensureClaudeImport(report, join(targetDir, "CLAUDE.md"));
148
+ }
149
+ if (options.harnesses.includes("codex")) {
150
+ installKitFile(join(".agents", "skills", SKILL_NAME, "SKILL.md"), skill);
151
+ }
152
+ if (options.harnesses.includes("opencode")) {
153
+ for (const role of ROLES) {
154
+ installKitFile(join(".opencode", "agents", `${role}.md`), composeOpencodeAgent(role, options.models[role]));
155
+ }
156
+ }
157
+ // The manifest records applied state, so it is written last and only when
158
+ // something actually differs; a plain re-run stays a byte-for-byte no-op.
159
+ const desired = {
160
+ kit: SKILL_NAME,
161
+ version: PACKAGE_VERSION,
162
+ harnesses: [...options.harnesses].sort(),
163
+ models: options.models,
164
+ files: installedFiles,
165
+ };
166
+ const manifestPath = join(targetDir, MANIFEST_PATH);
167
+ if (previous &&
168
+ JSON.stringify({
169
+ kit: previous.kit,
170
+ version: previous.version,
171
+ harnesses: previous.harnesses,
172
+ models: previous.models,
173
+ files: previous.files,
174
+ }) === JSON.stringify(desired)) {
175
+ report.skipped.push(manifestPath);
176
+ }
177
+ else {
178
+ const manifest = {
179
+ ...desired,
180
+ installedAt: previous?.installedAt || new Date().toISOString(),
181
+ };
182
+ installFile(report, manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
183
+ force: true,
184
+ });
185
+ }
186
+ return report;
187
+ }
@@ -0,0 +1,26 @@
1
+ export type Role = "task-slicer" | "implementer" | "reviewer";
2
+ export declare const ROLES: Role[];
3
+ export type ModelAlias = "sonnet" | "opus" | "haiku";
4
+ export declare const MODEL_ALIASES: ModelAlias[];
5
+ /**
6
+ * Per-role defaults. The orchestrator itself runs on the session model and is
7
+ * deliberately not configured here.
8
+ */
9
+ export declare const DEFAULT_MODELS: Record<Role, string>;
10
+ export declare function isModelAlias(value: string): value is ModelAlias;
11
+ /**
12
+ * Claude Code subagent frontmatter accepts the aliases directly as well as
13
+ * full model ids, so the chosen value passes through unchanged.
14
+ */
15
+ export declare function claudeModelValue(model: string): string;
16
+ export declare function opencodeModelValue(model: string): string;
17
+ /**
18
+ * Model values are interpolated into YAML frontmatter as plain scalars;
19
+ * reject anything that could break out of that position.
20
+ */
21
+ export declare function assertValidModelId(model: string): void;
22
+ /**
23
+ * Parses a `--models` spec like `implementer=haiku,reviewer=opus` on top of
24
+ * the given base mapping. Unknown roles and empty values are rejected.
25
+ */
26
+ export declare function parseModelsSpec(spec: string, base: Record<Role, string>): Record<Role, string>;
package/dist/models.js ADDED
@@ -0,0 +1,72 @@
1
+ export const ROLES = ["task-slicer", "implementer", "reviewer"];
2
+ export const MODEL_ALIASES = ["sonnet", "opus", "haiku"];
3
+ /**
4
+ * Per-role defaults. The orchestrator itself runs on the session model and is
5
+ * deliberately not configured here.
6
+ */
7
+ export const DEFAULT_MODELS = {
8
+ "task-slicer": "sonnet",
9
+ implementer: "sonnet",
10
+ reviewer: "opus",
11
+ };
12
+ /**
13
+ * opencode expects fully qualified `provider/model-id` strings (models.dev
14
+ * ids). These are the current Anthropic ids for the three aliases; targets
15
+ * with a different provider setup can pass a custom id instead.
16
+ */
17
+ const OPENCODE_MODEL_IDS = {
18
+ sonnet: "anthropic/claude-sonnet-4-6",
19
+ opus: "anthropic/claude-opus-4-8",
20
+ haiku: "anthropic/claude-haiku-4-5",
21
+ };
22
+ export function isModelAlias(value) {
23
+ return MODEL_ALIASES.includes(value);
24
+ }
25
+ /**
26
+ * Claude Code subagent frontmatter accepts the aliases directly as well as
27
+ * full model ids, so the chosen value passes through unchanged.
28
+ */
29
+ export function claudeModelValue(model) {
30
+ return model;
31
+ }
32
+ export function opencodeModelValue(model) {
33
+ if (isModelAlias(model))
34
+ return OPENCODE_MODEL_IDS[model];
35
+ return model.includes("/") ? model : `anthropic/${model}`;
36
+ }
37
+ /**
38
+ * Model values are interpolated into YAML frontmatter as plain scalars;
39
+ * reject anything that could break out of that position.
40
+ */
41
+ export function assertValidModelId(model) {
42
+ if (model.length === 0) {
43
+ throw new Error("Model id must not be empty");
44
+ }
45
+ if (/[:"'#\n\\]/.test(model) || model !== model.trim()) {
46
+ throw new Error(`Invalid model id "${model}"; expected an alias (${MODEL_ALIASES.join(", ")}) or a plain id like anthropic/claude-opus-4-8`);
47
+ }
48
+ }
49
+ /**
50
+ * Parses a `--models` spec like `implementer=haiku,reviewer=opus` on top of
51
+ * the given base mapping. Unknown roles and empty values are rejected.
52
+ */
53
+ export function parseModelsSpec(spec, base) {
54
+ const result = { ...base };
55
+ for (const pair of spec.split(",")) {
56
+ const trimmed = pair.trim();
57
+ if (trimmed === "")
58
+ continue;
59
+ const eq = trimmed.indexOf("=");
60
+ if (eq <= 0 || eq === trimmed.length - 1) {
61
+ throw new Error(`Invalid --models entry "${trimmed}"; expected role=model`);
62
+ }
63
+ const role = trimmed.slice(0, eq).trim();
64
+ const model = trimmed.slice(eq + 1).trim();
65
+ if (!ROLES.includes(role)) {
66
+ throw new Error(`Unknown role "${role}" in --models; valid roles: ${ROLES.join(", ")}`);
67
+ }
68
+ assertValidModelId(model);
69
+ result[role] = model;
70
+ }
71
+ return result;
72
+ }