diff --git a/README.md b/README.md index 7da4060..ca88ceb 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The SSH/rsync path has been verified end to end against a disposable POSIX target, including remote locking, atomic installation, checksum comparison, and cleanup. -## Run a role remotely +## Run a configured role `run-role` turns a configured role into the selected Harness command. Without `--execute` it only prints the plan: @@ -189,22 +189,58 @@ and cleanup. ```bash npm run run-role -- --harness opencode --role explorer \ --prompt "Inspect the repository and do not edit files" \ - --environment fleet-ssh + --environment fleet-ssh --model auto ``` Add `--execute` to run the command. SSH environments pass the prompt through strict POSIX quoting or a PowerShell encoded command, depending on `shell`. +Model roles live in `~/.config/mstack/models.json`. Each role accepts a model +string. The `reviewer` role also accepts a non-empty list of unique model strings. +A Harness override replaces the role's whole value. Use `/setup-mstack` to choose +names reported by your Harness; existing string configurations remain valid. + +`inherit-parent` requests the current chat model. Native delegation can use its +documented inheritance mechanism, but `run-role` starts a new CLI process and +requires `--parent-model ` for that value. `auto` omits the +model argument and selects the CLI's default, which may be different. Existing +CLI calls that used `inherit-parent` must add `--parent-model`, or explicitly +choose `--model auto`. With no configuration file, the seven roles default to +`inherit-parent`, so the same choice is required. + +For a configured reviewer list, select one entry with `--model-index 0` or run +all entries concurrently: + +```bash +npm run run-role -- --harness codex --role reviewer \ + --file ~/.config/mstack/models.json --prompt "Review this diff" \ + --all-models --read-only --execute +``` + +Replace `roles.reviewer` or `overrides.codex.reviewer` with a JSON array of the +model names you selected. Add `--parent-model ` if an entry +is `inherit-parent`. Without `--execute`, fanout prints a JSON array of plans. +Execution returns a JSON array with each model's `stdout`, `stderr`, and `status`, +in configuration order. A string role used with `--all-models` returns the same +array shape with one entry. Any failure makes the overall exit code nonzero. Output +is buffered up to 16 MiB per stream per worker; larger output fails that worker. +`--all-models` requires `--read-only`. Writable workers need individual launches +with separate worktrees. Review the Harness's read-only limits below. + ## Smoke test Harnesses -Check CLI availability and skill discovery after installation: +Check CLI availability and installed skill files after installation: ```bash npm run smoke-harnesses -- --harness all --require-installed ``` -Add `--execute` for a live prompt on every available Harness. With -`--read-only`, Codex, Claude Code, and pi use CLI-enforced tool restrictions. +Add `--execute` for a live prompt on every available Harness. It uses the CLI +default model unless you supply `--model`, `--parent-model`, or a `--file` with +model choices. An inherited role in that file needs `--parent-model`. The live +prompt checks a reply marker; it does not prove that the Harness loaded the skill +or completed its workflow. These live calls always use `--read-only`. +Codex, Claude Code, and pi use CLI-enforced tool restrictions. OpenCode selects its built-in `plan` agent, which denies direct edits but still allows shell commands in OpenCode 1.18; use a disposable checkout when its prompt-only write boundary is insufficient. A live check also needs that diff --git a/docs/guide/01-setup.md b/docs/guide/01-setup.md index 2c8d562..d4a6379 100644 --- a/docs/guide/01-setup.md +++ b/docs/guide/01-setup.md @@ -23,9 +23,9 @@ Run: [`/setup-mstack`](../../skills/setup-mstack/SKILL.md) detects the models you have access to, shows you each role (code delegates, judgment, the review panels), and asks what you want. Answer the questions. It writes `~/.config/mstack/models.json`, the portable configuration every mstack skill reads. -You only override what you care about. A role with no entry keeps the skill's default. To restore it later, delete that role's entry, or run `/setup-mstack` again. +You only override what you care about. To restore a role to the parent chat model, set it to `inherit-parent` or run `/setup-mstack` again. Setup preserves other choices and Harness overrides. -You might be wondering what happens if you use Auto. Set a role to `inherit-parent` or `auto` and mstack omits the subagent `model` field, so the subagent inherits your parent chat model. Both values mean the same thing, and neither is a model slug. For a panel role the value is a list, and one subagent runs per entry, so the list length sets the panel size. Setup also configures `swarm workers`, the default model for every `/swarm` worker unless a race names a model for each arm. +Use `inherit-parent` for the current chat model through the Harness's documented native inheritance. A new CLI process needs `run-role --parent-model ` to use that same model. `auto` requests the Harness's default selection, which may differ from the chat model. Neither value is a model name. The `reviewer` role accepts a model string or a non-empty list of unique model strings. `/interrogate` runs one reviewer per list entry. CLI fanout uses `--all-models --read-only`; select just one entry with `--model-index 0`. The `implementer` role sets the default model for `/swarm` workers unless a race names a model for each arm. ## Accept the verification offer, or don't diff --git a/docs/guide/10-recipes-and-pitfalls.md b/docs/guide/10-recipes-and-pitfalls.md index 06a1110..6477791 100644 --- a/docs/guide/10-recipes-and-pitfalls.md +++ b/docs/guide/10-recipes-and-pitfalls.md @@ -85,7 +85,7 @@ That's the whole prompt. [`/bro`](../../skills/bro/SKILL.md) restates the last m - **Parallel agents in one worktree.** They overwrite each other and the diff becomes archaeology. Say "own worktree per attempt" and the isolation is free. - **Using `/arena` for coverage.** `/arena` repeats one design or code brief, then picks a base and grafts the best parts. `/swarm` partitions slices or declared race arms and aggregates one report. - **Accepting every review comment.** Bots and humans both file real catches and noise in one list. `/interrogate` sorts findings into act-on and dismissed buckets with reasons, and you can override either way. -- **Treating `auto` as a model slug.** `auto` and `inherit-parent` mean "omit the model field so the subagent inherits the parent chat model." [Setup](./01-setup.md) covers the roles. +- **Treating `auto` as a model slug.** `auto` requests the Harness default; `inherit-parent` requests the chat model through documented native inheritance. A new CLI process needs `--parent-model ` to use the chat model. [Setup](./01-setup.md) covers the roles. - **Reporting success off a green build.** A build proves it compiles. Ask for the real command, flow, stored value, or profile, and expect the evidence in the reply. - **Writing a `SKILL.md` freehand.** Route it through the [Authoring or modifying a skill playbook](../../skills/meta-mode/playbooks/authoring-a-skill.md) so validation and review happen. diff --git a/package.json b/package.json index 7bdfcce..53a5507 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "install-skills": "node scripts/install.mjs", "optimize-context": "node scripts/optimize-context.mjs", "reconcile-context": "node scripts/reconcile-context.mjs", - "test": "node scripts/validate.mjs && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs" + "test": "node scripts/validate.mjs && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs" }, "bin": { "mstack": "scripts/install.mjs" diff --git a/profiles/models.example.json b/profiles/models.example.json index ee5cb9a..b17b802 100644 --- a/profiles/models.example.json +++ b/profiles/models.example.json @@ -1,7 +1,7 @@ { "roles": { "implementer": "inherit-parent", - "reviewer": "inherit-parent", + "reviewer": ["inherit-parent"], "judge": "inherit-parent", "explorer": "inherit-parent", "synthesizer": "inherit-parent", diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index 2c600a5..dab55e0 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -21,7 +21,7 @@ "automations/benny/templates/triage-automation-prompt.md": "e0bba594547834f013ec805915610eb597e7929efe3ffbe85ae00339ee16d474" }, "guide": { - "docs/guide/01-setup.md": "b6ef89aa895f1fe19cec0385e3657d1ca3bb3bf888fc7a247c9efdbbcb56dc2b", + "docs/guide/01-setup.md": "6786c9090de6092d87836214b690e2b5640ddc7f34b19c0b9e2af794fcc17e7e", "docs/guide/02-meta-mode.md": "c169bbee5eae6ea6b16ea4d3df1442b05031778b20eb9af8cc19c1a7bac5282a", "docs/guide/03-understand.md": "5db58a1fc2cc7bf1843945fcc7d93eba4f73e26fde5cd097d006be641fe5883e", "docs/guide/04-design.md": "216bc41d25e3481333acfffef4ea486ce8dabcc5dfa736d16eb0afd979aa03a1", @@ -30,7 +30,7 @@ "docs/guide/07-overnight.md": "060294d4dbda952e3211e285f75e471c8d454d7705767b42377c4e1963841ad2", "docs/guide/08-principles.md": "4dba3340352310e432905a27dd70552b4e034bb7d37a55dcab59a4b378e59ef1", "docs/guide/09-make-it-yours.md": "04b21905a0a152ea9e57dcbecf25112112a1e7584037feabf501132f2d8cc64c", - "docs/guide/10-recipes-and-pitfalls.md": "425b08f452cf1ca2b95ce8c035ae8c8d0e1b315794ac35c45c3c8e45cc3f15a5", + "docs/guide/10-recipes-and-pitfalls.md": "56b6e1360c070029ac26ec39f00f7c6c587f1203255682808ae93b3176f90746", "docs/guide/images/design.jpg": "b8ed07427f6a0c37dd8ba97961fc0eb5071efeee6c4a230648bdb7547f6f2a8f", "docs/guide/images/overnight.jpg": "4b4405e4df8925c5b3abbe702f681af0f3b1f5f32271b599f95717f578f1a0c8", "docs/guide/images/recipes.jpg": "9fa2d51b476635712737795ddef81806efaa0086bb3bfa9f9f45695d9ab0f8e8", @@ -97,7 +97,7 @@ }, "interrogate": { "source": "8aef2c35f2282bbc2636e10b4b52fe41ed5d00c86e775e82fbf8771b2b07f9be", - "target": "8772ecf8ed7e64118459160d4cd61096877b52cc557104a3ae305b036be16ac9" + "target": "ab2508809da00eac47444b102cd7d11730f15b2476cd6cd947e330cc08814e78" }, "maintain-verification-skill": { "source": "515c0eaa054b3f6be1b1fb06f2c2f173c80fddb58bbcac57576f89c479bc68e8", @@ -109,7 +109,7 @@ }, "meta-mode": { "source": "9319963075ef537e1d83edc4814f6bc6a7c90779ea261227934c984a0a5e88fb", - "target": "65337fe034b2b34f782f2184e7df15bee535b58918a18efbe969206016c96b1e" + "target": "53cb6ae809072973802f78fac92fc75efbe111330271513fa756cd2f0d053ad4" }, "no-comments": { "source": "5c5b0882297d704c3a9720c52b7a793c68b013eaf717989f0945624efdfe2b05", @@ -213,11 +213,11 @@ }, "reflect": { "source": "6a4d4ccaace9ce88c3e268fcbedf3cfd40e527162a5e5aad6de8edbaf5e2b83f", - "target": "baedc055aceb55ed320db637c37a7e37270f5cbd654b41bd6246b158e172a5d4" + "target": "008d424f5a4570c0452852322591a50a75c4e90828f85bbb8662b5dabcc26e47" }, "setup-mstack": { "source": "a11137437e12831c73f228f449bdfefab487bf7e2c69a4b306189e8fb1e6ab3e", - "target": "5aeab4f18c5387b6c32ab72fd87c3b48fdfe419109e681045dfe42c155e33bcc" + "target": "555c577bf494baafafa980ee3f647149c180a615a706ef409845cd03cd16e142" }, "show-me-your-work": { "source": "831e85ba3f84f38bf338cd03e6af050fea357752e25a825e334c99f59d7f2077", diff --git a/profiles/upstreams.json b/profiles/upstreams.json index b51f595..954bf6b 100644 --- a/profiles/upstreams.json +++ b/profiles/upstreams.json @@ -30,6 +30,14 @@ "02-poteto-mode.md": "02-meta-mode.md" }, "replacements": [ + { + "from": "You might be wondering what happens if you use Auto. Set a role to `inherit-parent` or `auto` and mstack omits the subagent `model` field, so the subagent inherits your parent chat model. Both values mean the same thing, and neither is a model slug. For a panel role the value is a list, and one subagent runs per entry, so the list length sets the panel size. Setup also configures `swarm workers`, the default model for every `/swarm` worker unless a race names a model for each arm.", + "to": "Use `inherit-parent` for the current chat model through the Harness's documented native inheritance. A new CLI process needs `run-role --parent-model ` to use that same model. `auto` requests the Harness's default selection, which may differ from the chat model. Neither value is a model name. The `reviewer` role accepts a model string or a non-empty list of unique model strings. `/interrogate` runs one reviewer per list entry. CLI fanout uses `--all-models --read-only`; select just one entry with `--model-index 0`. The `implementer` role sets the default model for `/swarm` workers unless a race names a model for each arm." + }, + { + "from": "`auto` and `inherit-parent` mean \"omit the model field so the subagent inherits the parent chat model.\"", + "to": "`auto` requests the Harness default; `inherit-parent` requests the chat model through documented native inheritance. A new CLI process needs `--parent-model ` to use the chat model." + }, { "from": "## Install the plugin", "to": "## Install the skills" @@ -52,7 +60,7 @@ }, { "from": "A role with no line in the rule keeps the skill's default. To restore a default later, delete that role's line, or just run `/setup-mstack` again.", - "to": "A role with no entry keeps the skill's default. To restore it later, delete that role's entry, or run `/setup-mstack` again." + "to": "To restore a role to the parent chat model, set it to `inherit-parent` or run `/setup-mstack` again. Setup preserves other choices and Harness overrides." }, { "from": "`.cursor/skills/verify-/`, a project-local skill that teaches agents to drive your app the way a user does. It proves the skill works once before handing it over.", diff --git a/scripts/cli-args.mjs b/scripts/cli-args.mjs new file mode 100644 index 0000000..1f30608 --- /dev/null +++ b/scripts/cli-args.mjs @@ -0,0 +1,17 @@ +export function parseCliArgs(args, valueFlags, switchFlags = []) { + const options = {}; + for (let index = 0; index < args.length; index++) { + const flag = args[index]; + if (Object.hasOwn(options, flag)) throw new Error(`Duplicate option: ${flag}`); + if (switchFlags.includes(flag)) { + options[flag] = true; + } else if (valueFlags.includes(flag)) { + const value = args[++index]; + if (!value?.trim() || value.startsWith("--")) throw new Error(`${flag} requires a value`); + options[flag] = value; + } else { + throw new Error(`Unknown option: ${flag}`); + } + } + return options; +} diff --git a/scripts/context.test.mjs b/scripts/context.test.mjs index cd17e53..f402f0c 100644 --- a/scripts/context.test.mjs +++ b/scripts/context.test.mjs @@ -178,19 +178,21 @@ test("model configuration uses the platform home directory by default", () => { } }); -test("run-role emits a harness command and omits inherited model flags", () => { +test("run-role emits a harness command using the explicit CLI default", () => { const result = run(runRole, [ "--harness", "pi", "--role", "implementer", + "--model", + "auto", "--prompt", "inspect the repository", ]); assert.equal(result.status, 0, result.stderr); const plan = JSON.parse(result.stdout); assert.equal(plan.harness, "pi"); - assert.equal(plan.model, "inherit-parent"); + assert.equal(plan.model, "auto"); assert.equal(plan.command, "pi"); assert.deepEqual(plan.args, ["-p", "--no-session", "inspect the repository"]); }); diff --git a/scripts/environment.test.mjs b/scripts/environment.test.mjs index 45dc180..808403b 100644 --- a/scripts/environment.test.mjs +++ b/scripts/environment.test.mjs @@ -62,6 +62,7 @@ test("run-role emits an SSH plan for a named environment", () => { "--prompt", "say 'hello'; do not edit", "--environment", "fleet", "--file", join(root, "missing-models.json"), + "--model", "auto", ], { cwd: resolve("."), env: { ...process.env, MSTACK_ENVIRONMENTS_FILE: configPath }, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); const plan = JSON.parse(result.stdout); @@ -97,6 +98,7 @@ test("environment consumers reject a relative MSTACK_ENVIRONMENTS_FILE", () => { "--prompt", "inspect", "--environment", "fleet", "--file", join(root, "missing-models.json"), + "--model", "auto", ], { cwd: root, env, encoding: "utf8" }); for (const result of [installResult, roleResult]) { @@ -113,6 +115,7 @@ test("Codex runtime uses flags supported by the current exec CLI", () => { "--harness", "codex", "--role", "explorer", "--prompt", "inspect the repository", + "--model", "auto", ], { cwd: resolve("."), encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); const plan = JSON.parse(result.stdout); @@ -125,6 +128,7 @@ test("OpenCode read-only plans select the built-in plan agent", () => { "--harness", "opencode", "--role", "explorer", "--prompt", "inspect the repository", + "--model", "auto", "--read-only", ], { cwd: resolve("."), encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); diff --git a/scripts/model-config-lib.mjs b/scripts/model-config-lib.mjs index a10a686..2ce0c1c 100644 --- a/scripts/model-config-lib.mjs +++ b/scripts/model-config-lib.mjs @@ -15,6 +15,20 @@ export function readModelConfig(path) { return JSON.parse(readFileSync(path, "utf8")); } +export function isValidModel(model) { + return typeof model === "string" && model.trim() === model && model.length > 0 && !model.includes("\0"); +} + +function validateRoleValue(role, model, path, errors) { + if (role === "reviewer" && Array.isArray(model)) { + if (!model.length || model.some((entry) => !isValidModel(entry)) || new Set(model).size !== model.length) { + errors.push(`${path} must be a non-empty list of unique model strings without surrounding whitespace or NUL characters`); + } + } else if (!isValidModel(model)) { + errors.push(`${path} must be a non-empty string without surrounding whitespace or NUL characters`); + } +} + export function validateModelConfig(config, harnesses) { const errors = []; if (!config || typeof config !== "object" || Array.isArray(config)) { @@ -25,9 +39,8 @@ export function validateModelConfig(config, harnesses) { errors.push("roles must be an object"); } else { for (const [role, model] of Object.entries(config.roles)) { - if (!role || typeof model !== "string" || model.length === 0) { - errors.push(`roles.${role || ""} must be a non-empty string`); - } + if (!role.trim()) errors.push("role names must not be empty"); + validateRoleValue(role, model, `roles.${role || ""}`, errors); } } if (config.overrides !== undefined && (!config.overrides || typeof config.overrides !== "object" || Array.isArray(config.overrides))) { @@ -41,7 +54,7 @@ export function validateModelConfig(config, harnesses) { } for (const [role, model] of Object.entries(overrides)) { if (!Object.hasOwn(config.roles ?? {}, role)) errors.push(`overrides.${harness}.${role} has no role default`); - if (typeof model !== "string" || model.length === 0) errors.push(`overrides.${harness}.${role} must be a non-empty string`); + validateRoleValue(role, model, `overrides.${harness}.${role}`, errors); } } return errors; diff --git a/scripts/model-config.mjs b/scripts/model-config.mjs index 1b7a395..41aa0a8 100644 --- a/scripts/model-config.mjs +++ b/scripts/model-config.mjs @@ -3,15 +3,18 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { readModelConfig, resolveModels, validateModelConfig } from "./model-config-lib.mjs"; +import { parseCliArgs } from "./cli-args.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const harnesses = Object.keys(JSON.parse(readFileSync(join(repoRoot, "profiles", "harnesses.json"), "utf8"))); -const args = process.argv.slice(2); - -function valueAfter(flag) { - const index = args.indexOf(flag); - return index === -1 ? undefined : args[index + 1]; +const options = parseCliArgs(process.argv.slice(2), ["--file", "--harness", "--role", "--format"]); +const requestedHarness = options["--harness"]; +if (requestedHarness && !harnesses.includes(requestedHarness)) { + throw new Error(`Unsupported harness: ${requestedHarness}`); } +const requestedRole = options["--role"]; +const format = options["--format"] ?? "text"; +if (!["text", "json"].includes(format)) throw new Error(`Unsupported format: ${format}`); function expandHome(path) { if (path === "~") return homedir(); @@ -19,8 +22,7 @@ function expandHome(path) { } const defaultPath = join(homedir(), ".config", "mstack", "models.json"); -const requestedPath = expandHome(valueAfter("--file") ?? defaultPath); -if (!requestedPath) throw new Error("--file requires a path"); +const requestedPath = expandHome(options["--file"] ?? defaultPath); const configPath = resolve(requestedPath); if (!existsSync(configPath)) { console.log(`No model configuration found at ${configPath}`); @@ -35,16 +37,9 @@ if (errors.length) { process.exit(1); } -const requestedHarness = valueAfter("--harness"); -if (requestedHarness && !harnesses.includes(requestedHarness)) { - throw new Error(`Unsupported harness: ${requestedHarness}`); -} -const requestedRole = valueAfter("--role"); if (requestedRole && !Object.hasOwn(config.roles, requestedRole)) { throw new Error(`Unknown model role: ${requestedRole}`); } -const format = valueAfter("--format") ?? "text"; -if (!["text", "json"].includes(format)) throw new Error(`Unsupported format: ${format}`); const selectedHarnesses = requestedHarness ? [requestedHarness] : harnesses; for (const harness of selectedHarnesses) { const values = requestedRole ? { [requestedRole]: resolveModels(config, harness)[requestedRole] } : resolveModels(config, harness); diff --git a/scripts/model-config.test.mjs b/scripts/model-config.test.mjs new file mode 100644 index 0000000..3f36083 --- /dev/null +++ b/scripts/model-config.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { validateModelConfig } from "./model-config-lib.mjs"; + +const harnesses = ["codex", "claude", "opencode", "pi"]; +const runRole = resolve("scripts/run-role.mjs"); +const checkModels = resolve("scripts/model-config.mjs"); + +function fixture(t, config = { roles: { reviewer: ["review-a", "review-b"] } }) { + const root = mkdtempSync(join(tmpdir(), "mstack-models 测试-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const file = join(root, "models.json"); + writeFileSync(file, JSON.stringify(config)); + const env = { ...process.env, HOME: root, USERPROFILE: root }; + const run = (args, script = runRole, extraEnv = {}) => spawnSync(process.execPath, [script, ...args], { + cwd: root, env: { ...env, ...extraEnv }, encoding: "utf8", timeout: 15000, + }); + return { root, file, run }; +} + +test("reviewer lists resolve as a whole Harness override", (t) => { + const { file, run } = fixture(t, { + roles: { reviewer: ["review-a", "review-b"], implementer: "auto" }, + overrides: { pi: { reviewer: ["pi-review-a", "pi-review-b"] } }, + }); + const result = run(["--file", file, "--harness", "pi", "--role", "reviewer", "--format", "json"], checkModels); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).models, { reviewer: ["pi-review-a", "pi-review-b"] }); +}); + +test("model configuration rejects empty, duplicate and non-reviewer lists", () => { + for (const value of [[], [""], [" "], ["review-a", "review-a"], ["review-a", null]]) { + assert.ok(validateModelConfig({ roles: { reviewer: value } }, harnesses).length, JSON.stringify(value)); + assert.ok(validateModelConfig({ roles: { reviewer: "auto" }, overrides: { pi: { reviewer: value } } }, harnesses).length); + } + assert.ok(validateModelConfig({ roles: { implementer: ["worker-a"] } }, harnesses).length); + assert.ok(validateModelConfig({ roles: { reviewer: " " } }, harnesses).length); + assert.deepEqual(validateModelConfig({ roles: { reviewer: "auto" } }, harnesses), []); +}); + +test("model configuration rejects padded names in defaults, lists and overrides", (t) => { + for (const config of [ + { roles: { reviewer: " review-a" } }, + { roles: { reviewer: ["review-a", "review-b "] } }, + { roles: { reviewer: "auto" }, overrides: { pi: { reviewer: "\treview-a" } } }, + ]) { + const { file, run } = fixture(t, config); + const result = run(["--file", file, "--harness", "pi"], checkModels); + assert.notEqual(result.status, 0, JSON.stringify(config)); + assert.match(result.stderr, /whitespace/); + } +}); + +test("explicit CLI model selections reject padded names before planning a worker", (t) => { + const { file, run } = fixture(t, { roles: { reviewer: "inherit-parent" } }); + const args = ["--harness", "pi", "--role", "reviewer", "--prompt", "inspect", "--file", file]; + for (const flag of ["--model", "--parent-model"]) { + for (const value of [" review-a", "review-a ", "\treview-a"]) { + const result = run([...args, flag, value]); + assert.notEqual(result.status, 0, `${flag}: ${JSON.stringify(value)}`); + assert.match(result.stderr, /whitespace/); + } + } +}); + +test("list execution requires explicit selection and preserves model arguments for every Harness", (t) => { + const { file, run } = fixture(t); + for (const harness of harnesses) { + const args = ["--harness", harness, "--role", "reviewer", "--prompt", "review this", "--file", file]; + const ambiguous = run(args); + assert.notEqual(ambiguous.status, 0); + assert.match(ambiguous.stderr, /--all-models.*--model-index/); + const selected = run([...args, "--model-index", "1"]); + assert.equal(selected.status, 0, selected.stderr); + const plan = JSON.parse(selected.stdout); + assert.equal(plan.model, "review-b"); + assert.equal(plan.args[plan.args.indexOf("--model") + 1], "review-b"); + const all = run([...args, "--all-models", "--read-only"]); + assert.equal(all.status, 0, all.stderr); + assert.deepEqual(JSON.parse(all.stdout).map((entry) => entry.model), ["review-a", "review-b"]); + } +}); + +test("inherit-parent requires the parent model while auto selects the CLI default", (t) => { + const { file, run } = fixture(t, { roles: { reviewer: "inherit-parent" } }); + const args = ["--harness", "pi", "--role", "reviewer", "--prompt", "inspect", "--file", file]; + const missing = run(args); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /--parent-model/); + const inherited = run([...args, "--parent-model", "known-parent"]); + assert.equal(inherited.status, 0, inherited.stderr); + assert.equal(JSON.parse(inherited.stdout).model, "known-parent"); + assert.deepEqual(JSON.parse(inherited.stdout).args, ["-p", "--no-session", "--model", "known-parent", "inspect"]); + const automatic = run([...args, "--model", "auto"]); + assert.equal(automatic.status, 0, automatic.stderr); + assert.deepEqual(JSON.parse(automatic.stdout).args, ["-p", "--no-session", "inspect"]); + for (const parent of ["auto", "inherit-parent"]) { + assert.notEqual(run([...args, "--parent-model", parent]).status, 0); + } +}); + +test("model CLIs reject missing optional flag values before fallback or file reads", (t) => { + const { file, run } = fixture(t, { roles: { reviewer: "auto" } }); + for (const flag of ["--file", "--harness", "--role", "--format"]) { + for (const suffix of [[flag], [flag, "--unknown"]]) { + const result = run(suffix, checkModels); + assert.notEqual(result.status, 0, `${flag}: ${result.stdout}`); + assert.match(result.stderr, new RegExp(`${flag} requires a value`)); + } + } + for (const flag of ["--file", "--model", "--parent-model", "--model-index", "--environment", "--cwd"]) { + const base = ["--harness", "pi", "--role", "reviewer", "--prompt", "inspect"]; + if (flag !== "--file") base.push("--file", file); + for (const suffix of [[flag], [flag, "--execute"]]) { + const result = run([...base, ...suffix]); + assert.notEqual(result.status, 0, flag); + assert.match(result.stderr, new RegExp(`${flag} requires a value`)); + } + } +}); + +test("selection flags reject conflicts, invalid indexes and writable fanout", (t) => { + const { file, run } = fixture(t); + const args = ["--harness", "pi", "--role", "reviewer", "--prompt", "review", "--file", file]; + for (const flags of [ + ["--model-index", "-1"], ["--model-index", "2"], ["--model-index", "1.5"], + ["--model-index", "0", "--all-models", "--read-only"], + ["--model", "explicit", "--all-models", "--read-only"], + ["--model", "explicit", "--model-index", "0"], ["--all-models"], + ]) assert.notEqual(run([...args, ...flags]).status, 0, flags.join(" ")); +}); + +test("fanout executes every configured model and retains attributed output after one fails", (t) => { + const { root, file, run } = fixture(t); + const bin = join(root, "bin"); + mkdirSync(bin); + const helper = join(bin, "fake-cli.mjs"); + writeFileSync(helper, [ + 'import { appendFileSync, readFileSync } from "node:fs";', + 'import { setTimeout } from "node:timers/promises";', + 'const args = process.argv.slice(2);', + 'const model = args[args.indexOf("--model") + 1];', + 'appendFileSync(process.env.MSTACK_MODEL_TEST_LOG, model + "\\n");', + 'const deadline = Date.now() + 5000;', + 'while (readFileSync(process.env.MSTACK_MODEL_TEST_LOG, "utf8").trim().split("\\n").length < 2) {', + ' if (Date.now() > deadline) process.exit(91);', + ' await setTimeout(10);', + '}', + 'console.log(JSON.stringify({ args, proof: "x".repeat(128 * 1024) }));', + 'console.error("stderr:" + model);', + 'process.exitCode = process.env.MSTACK_MODEL_TEST_FAIL === "yes" && model === "review-b" ? 7 : 0;', + ].join("\n")); + if (process.platform === "win32") { + const quote = (value) => `'${value.replaceAll("'", "''")}'`; + writeFileSync(join(bin, "pi.ps1"), `\uFEFF& ${quote(process.execPath)} ${quote(helper)} @args\nexit $LASTEXITCODE\n`); + } else { + writeFileSync(join(bin, "pi"), `#!/usr/bin/env node\nimport(${JSON.stringify(helper)});\n`); + chmodSync(join(bin, "pi"), 0o755); + } + const log = join(root, "models.log"); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const args = [ + "--harness", "pi", "--role", "reviewer", "--prompt", "same prompt", + "--file", file, "--all-models", "--read-only", "--execute", + ]; + const env = { [pathKey]: `${bin}${process.platform === "win32" ? ";" : ":"}${process.env[pathKey]}`, MSTACK_MODEL_TEST_LOG: log }; + const result = run(args, runRole, { ...env, MSTACK_MODEL_TEST_FAIL: "yes" }); + assert.equal(result.status, 1, result.stderr); + const results = JSON.parse(result.stdout); + assert.deepEqual(results.map(({ model, status }) => ({ model, status })), [ + { model: "review-a", status: 0 }, { model: "review-b", status: 7 }, + ], result.stdout); + assert.deepEqual(readFileSync(log, "utf8").trim().split("\n").sort(), ["review-a", "review-b"]); + for (const entry of results) { + const output = JSON.parse(entry.stdout); + assert.deepEqual(output.args, ["-p", "--no-session", "--model", entry.model, "same prompt", "--tools", "read,grep,find,ls"]); + assert.equal(output.proof, "x".repeat(128 * 1024)); + assert.equal(entry.stderr.trim(), `stderr:${entry.model}`); + } + writeFileSync(log, ""); + const success = run(args, runRole, { ...env, MSTACK_MODEL_TEST_FAIL: "no" }); + assert.equal(success.status, 0, success.stderr); + assert.deepEqual(JSON.parse(success.stdout).map((entry) => entry.status), [0, 0]); +}); diff --git a/scripts/run-role.mjs b/scripts/run-role.mjs index c90af78..b768265 100644 --- a/scripts/run-role.mjs +++ b/scripts/run-role.mjs @@ -3,24 +3,29 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { buildSshInvocation, environmentCwd, readEnvironment } from "./environment-lib.mjs"; -import { readModelConfig, resolveRoleModel, validateModelConfig } from "./model-config-lib.mjs"; +import { isValidModel, readModelConfig, resolveRoleModel, validateModelConfig } from "./model-config-lib.mjs"; import { processInvocation } from "./runtime-lib.mjs"; +import { parseCliArgs } from "./cli-args.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const harnessRegistry = JSON.parse(readFileSync(join(repoRoot, "profiles", "harnesses.json"), "utf8")); -const args = process.argv.slice(2); +const options = parseCliArgs(process.argv.slice(2), [ + "--harness", "--role", "--prompt", "--environment", "--file", "--model", + "--parent-model", "--model-index", "--cwd", +], ["--execute", "--read-only", "--all-models"]); -function valueAfter(flag) { - const index = args.indexOf(flag); - return index === -1 ? undefined : args[index + 1]; +for (const flag of ["--model", "--parent-model"]) { + if (options[flag] !== undefined && !isValidModel(options[flag])) { + throw new Error(`${flag} must be a non-empty model string without surrounding whitespace or NUL characters`); + } } function requireValue(flag) { - const value = valueAfter(flag); - if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`); + const value = options[flag]; + if (!value) throw new Error(`${flag} requires a value`); return value; } @@ -32,35 +37,64 @@ function expandHome(path) { const harness = requireValue("--harness"); const role = requireValue("--role"); const prompt = requireValue("--prompt"); -const environmentName = valueAfter("--environment"); -const configPath = valueAfter("--file") ?? join(homedir(), ".config", "mstack", "models.json"); +const environmentName = options["--environment"]; +const configPath = options["--file"] ?? join(homedir(), ".config", "mstack", "models.json"); const config = readModelConfig(resolve(expandHome(configPath))); const errors = validateModelConfig(config, Object.keys(harnessRegistry)); if (errors.length) throw new Error(errors.join("; ")); if (!Object.hasOwn(harnessRegistry, harness)) throw new Error(`Unsupported harness: ${harness}`); -const model = valueAfter("--model") ?? resolveRoleModel(config, harness, role); +const configured = resolveRoleModel(config, harness, role); +const allModels = options["--all-models"] === true; +const modelIndex = options["--model-index"]; +if ([options["--model"] !== undefined, allModels, modelIndex !== undefined].filter(Boolean).length > 1) { + throw new Error("Choose only one of --model, --all-models or --model-index"); +} +if (allModels && !options["--read-only"]) { + throw new Error("--all-models requires --read-only; isolate writable workers in separate worktrees and launch them individually"); +} +const configuredModels = Array.isArray(configured) ? configured : [configured]; +let selectedModels; +if (options["--model"] !== undefined) { + selectedModels = [options["--model"]]; +} else if (modelIndex !== undefined) { + if (!/^\d+$/.test(modelIndex) || Number(modelIndex) >= configuredModels.length) { + throw new Error(`--model-index must be between 0 and ${configuredModels.length - 1}`); + } + selectedModels = [configuredModels[Number(modelIndex)]]; +} else { + if (Array.isArray(configured) && !allModels) { + throw new Error("Reviewer lists require --all-models or --model-index"); + } + selectedModels = configuredModels; +} +const parentModel = options["--parent-model"]; +if (parentModel === "inherit-parent" || parentModel === "auto") { + throw new Error("--parent-model requires a concrete model name reported by the parent Harness"); +} +const models = selectedModels.map((model) => { + if (model !== "inherit-parent") return model; + if (!parentModel) { + throw new Error("inherit-parent requires --parent-model in a new CLI process; use --model auto for the CLI default"); + } + return parentModel; +}); const definition = harnessRegistry[harness].runtime; if (!definition?.command || !Array.isArray(definition.headlessArgs) || !Array.isArray(definition.modelArgs)) { throw new Error(`Harness ${harness} has no runtime command definition`); } -const command = definition.command; -const commandArgs = [...definition.headlessArgs]; -if (model !== "inherit-parent" && model !== "auto") { - commandArgs.push(...definition.modelArgs.map((part) => part.replace("{model}", model))); -} -commandArgs.push(prompt); -if (args.includes("--read-only")) commandArgs.push(...(definition.readOnlyArgs ?? [])); - const environment = readEnvironment(environmentName, [harness]); -const cwd = environmentCwd(environment, harness, valueAfter("--cwd")); -const invocation = environment.transport === "ssh" - ? buildSshInvocation(environment, command, commandArgs, cwd) - : { command, args: commandArgs }; - -if (!args.includes("--execute")) { - console.log(JSON.stringify({ +const cwd = environmentCwd(environment, harness, options["--cwd"]); +const plans = models.map((model) => { + const commandArgs = [...definition.headlessArgs]; + if (model !== "auto") commandArgs.push(...definition.modelArgs.map((part) => part.replace("{model}", model))); + commandArgs.push(prompt); + if (options["--read-only"]) commandArgs.push(...(definition.readOnlyArgs ?? [])); + const invocation = environment.transport === "ssh" + ? buildSshInvocation(environment, definition.command, commandArgs, cwd) + : { command: definition.command, args: commandArgs }; + return { harness, role, model, @@ -68,18 +102,39 @@ if (!args.includes("--execute")) { transport: environment.transport, command: invocation.command, args: invocation.args, - })); - process.exit(0); -} - -const executable = environment.transport === "ssh" - ? invocation - : processInvocation(invocation.command, invocation.args); -const result = spawnSync(executable.command, executable.args, { - cwd: environment.transport === "ssh" ? process.cwd() : cwd ?? process.cwd(), - encoding: "utf8", - stdio: "inherit", - shell: false, + }; }); -if (result.error) throw result.error; -process.exit(result.status ?? 1); + +function executableFor(plan) { + return environment.transport === "ssh" ? plan : processInvocation(plan.command, plan.args); +} +const executionCwd = environment.transport === "ssh" ? process.cwd() : cwd ?? process.cwd(); +if (!options["--execute"]) { + console.log(JSON.stringify(allModels ? plans : plans[0])); +} else if (allModels) { + const results = await Promise.all(plans.map((plan) => new Promise((done) => { + const executable = executableFor(plan); + execFile(executable.command, executable.args, { + cwd: executionCwd, encoding: "utf8", shell: false, windowsHide: true, maxBuffer: 16 * 1024 * 1024, + }, (error, stdout, stderr) => done({ + ...plan, + status: error ? (typeof error.code === "number" ? error.code : 1) : 0, + ...(error ? { error: error.message } : {}), + stdout, + stderr, + })); + }))); + console.log(JSON.stringify(results)); + process.exitCode = results.some((result) => result.status !== 0) ? 1 : 0; +} else { + const executable = executableFor(plans[0]); + const result = spawnSync(executable.command, executable.args, { + cwd: executionCwd, + encoding: "utf8", + stdio: "inherit", + shell: false, + windowsHide: true, + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} diff --git a/scripts/smoke-harnesses.mjs b/scripts/smoke-harnesses.mjs index 944fded..40d56e8 100644 --- a/scripts/smoke-harnesses.mjs +++ b/scripts/smoke-harnesses.mjs @@ -6,15 +6,13 @@ import { dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { processInvocation } from "./runtime-lib.mjs"; +import { parseCliArgs } from "./cli-args.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const registry = JSON.parse(readFileSync(join(repoRoot, "profiles", "harnesses.json"), "utf8")); -const args = process.argv.slice(2); - -function valueAfter(flag) { - const index = args.indexOf(flag); - return index === -1 ? undefined : args[index + 1]; -} +const options = parseCliArgs(process.argv.slice(2), [ + "--harness", "--skill", "--file", "--model", "--parent-model", +], ["--execute", "--require-installed", "--json"]); function expandHome(path) { if (path === "~") return homedir(); @@ -55,16 +53,16 @@ function cleanLines(value) { .filter(Boolean); } -const requested = valueAfter("--harness") ?? "all"; +const requested = options["--harness"] ?? "all"; const harnesses = requested === "all" ? Object.keys(registry) : requested.split(","); const unknown = harnesses.filter((harness) => !Object.hasOwn(registry, harness)); if (!harnesses.length || unknown.length) throw new Error(`Unsupported harness: ${unknown.join(", ") || requested}`); if (new Set(harnesses).size !== harnesses.length) throw new Error(`Duplicate harness in --harness: ${requested}`); -const skill = valueAfter("--skill") ?? "meta-mode"; +const skill = options["--skill"] ?? "meta-mode"; if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(skill)) throw new Error(`Invalid skill name: ${skill}`); -const execute = args.includes("--execute"); -const requireInstalled = args.includes("--require-installed"); +const execute = options["--execute"]; +const requireInstalled = options["--require-installed"]; const results = []; for (const harness of harnesses) { @@ -82,7 +80,7 @@ for (const harness of harnesses) { version: version.output.split(/\r?\n/)[0] ?? "", }; if (execute && version.ok) { - const modelConfig = valueAfter("--file"); + const modelConfig = options["--file"]; const roleArgs = [ join(repoRoot, "scripts", "run-role.mjs"), "--harness", @@ -95,6 +93,9 @@ for (const harness of harnesses) { "--read-only", ]; if (modelConfig) roleArgs.push("--file", modelConfig); + if (options["--parent-model"]) roleArgs.push("--parent-model", options["--parent-model"]); + if (options["--model"]) roleArgs.push("--model", options["--model"]); + else if (!modelConfig && !options["--parent-model"]) roleArgs.push("--model", "auto"); const live = spawnSync(process.execPath, roleArgs, { encoding: "utf8", stdio: "pipe" }); const lines = cleanLines(`${live.stdout ?? ""}${live.stderr ?? ""}`); entry.live = live.status === 0 && lines.includes("mstack-smoke-ok") ? "passed" : "failed"; @@ -103,7 +104,7 @@ for (const harness of harnesses) { results.push(entry); } -const output = args.includes("--json") ? JSON.stringify(results, null, 2) : results.map((entry) => { +const output = options["--json"] ? JSON.stringify(results, null, 2) : results.map((entry) => { const live = entry.live ? `, live ${entry.live}` : ""; return `${entry.harness}: CLI ${entry.cli}, ${entry.skill} ${entry.installed ? "installed" : "missing"}${live} (${entry.target})`; }).join("\n"); diff --git a/skills/interrogate/SKILL.md b/skills/interrogate/SKILL.md index 9b1669b..fcc59a1 100644 --- a/skills/interrogate/SKILL.md +++ b/skills/interrogate/SKILL.md @@ -32,7 +32,7 @@ Write one clear paragraph. If you're unsure about the intent, ask the user befor ## Step 3, Spawn Reviewers -Launch all reviewers in a single message using the delegation tool. Use the configured `reviewer` role from mstack; when the configuration contains a reviewer list, launch one per entry. Otherwise launch the Harness's normal reviewer once and record that no model race was configured. +Read `~/.config/mstack/models.json` when it exists. Select `overrides..reviewer` before `roles.reviewer`, defaulting to `inherit-parent`. A string selects one reviewer; a non-empty list selects one per entry. Launch them together using the delegation tool, within its concurrency limit. With one reviewer, record that no model race was configured. | Subagent | Default model | |----------|---------------| @@ -40,10 +40,23 @@ Launch all reviewers in a single message using the delegation tool. Use the conf For each reviewer: - `worker role`: `reviewer` -- `model`: the configured mstack `reviewer` role, or `inherit-parent` when no override exists +- `model`: this reviewer's single model string from the resolved role - `readonly`: `true` -If a selected model is rejected as unresolvable, omit the model so the active Harness inherits the parent, record the fallback, and continue the review. Do not write a guessed model name into the shared configuration. +Resolve `inherit-parent` through the Harness's documented native inheritance. +For `auto`, use its documented default model selection. If a selected model is +unavailable, record the failed selection and any fallback's actual model. Do not +count repeated fallback models as independent model agreement or save a guessed +model name in the configuration. + +If native delegation cannot select the configured models and an mstack checkout +or package directory is available, use `node scripts/run-role.mjs --harness +--role reviewer --file --prompt --all-models +--read-only --cwd --execute` from that directory. Each result includes its model, +stdout, stderr, and exit status. `inherit-parent` also needs +`--parent-model `. To request one reviewer on the new CLI's +default model, replace `--all-models` with `--model auto`. When only the skills are installed, +use the Harness's available delegation and report any model-selection limit. Read `references/reviewer-prompt.md` and fill in the template with: 1. The stated intent diff --git a/skills/meta-mode/SKILL.md b/skills/meta-mode/SKILL.md index cca0917..1e0ed45 100644 --- a/skills/meta-mode/SKILL.md +++ b/skills/meta-mode/SKILL.md @@ -131,7 +131,14 @@ worker a focused file or question and read its complete result before deciding. Choose models by difficulty. Route cross-cutting design, concurrency, and subtle algorithms to the strongest configured judgment role. Route trivial mechanical edits to the fast implementer role. Role-specific settings override these -defaults, and `inherit-parent` uses the parent chat model. +defaults. Resolve `inherit-parent` through the Harness's documented native +inheritance; `auto` uses its documented default model selection. A new CLI process +does not inherit the parent chat's model. With `run-role`, pass the known parent +model as `--parent-model `, or choose `--model auto` for the CLI default. +For a reviewer list, assign one string per worker in configuration order and +cycle when a fixed-size workflow needs more workers. `interrogate` instead runs +one reviewer per entry. CLI fanout via `--all-models` requires `--read-only`; +launch writable workers individually with separate worktrees. You own every subagent's work. Review the diff and write your own summary, don't pass through what it said. Interrupt-chained resumes silently drop directives, so fire a fresh subagent with consolidated scope rather than trusting a "done" summary. A second opinion is the same prompt against a different model. Agreement is high-signal. diff --git a/skills/reflect/SKILL.md b/skills/reflect/SKILL.md index 20853f8..0048aa4 100644 --- a/skills/reflect/SKILL.md +++ b/skills/reflect/SKILL.md @@ -35,6 +35,9 @@ One delegation request with three reviewers, an explicit model on each, and agen | Tooling | the active mstack `reviewer` role, default `inherit-parent` | `references/tooling-reviewer.md` | | Divergent | the active mstack `reviewer` role, default `inherit-parent` | `references/divergent-reviewer.md` | +If the resolved `reviewer` role is a list, assign its model strings in order +across the three lenses, cycling when there are fewer than three entries. + Pass each template verbatim, substituting the transcript path or digest where marked. Reviewers return findings in the delegation result body. ### 3. Synthesize diff --git a/skills/setup-mstack/SKILL.md b/skills/setup-mstack/SKILL.md index cf60fe8..da0686a 100644 --- a/skills/setup-mstack/SKILL.md +++ b/skills/setup-mstack/SKILL.md @@ -12,9 +12,12 @@ portable so the same role names work in every supported Harness. 1. Detect the model names available in the current Harness. 2. Read `~/.config/mstack/models.json` when it exists. -3. Start from `profiles/models.example.json` when no file exists. -4. Keep `inherit-parent` or `auto` when the user wants the role to use the - current session model. +3. When no file exists, start from the seven-role example below. A repository + checkout also provides `profiles/models.example.json`. +4. Use `inherit-parent` for the current session model. Native delegation must + support inheritance; a new CLI process needs the concrete parent model via + `run-role --parent-model `. Use `auto` for the Harness's default model + selection, which may differ from the current session. Do not write a model name that the current Harness did not report as available. @@ -31,8 +34,13 @@ Use these roles: - `candidate` for independent alternatives evaluated by an arena. - `operator` for environment or lifecycle operations. -Write the complete file on every run. A later run must produce the same file -when the choices have not changed. +Merge the requested changes into the existing file. Preserve other role choices, +Harness overrides, and unrelated fields. Fill missing roles with `inherit-parent`. +Write the complete JSON file only when its values change. + +Every role accepts one non-empty model string. `reviewer` also accepts a non-empty +list of unique model strings. `interrogate` runs one reviewer per list entry; +fixed-size review workflows select entries in order and cycle when needed. Example: @@ -40,7 +48,7 @@ Example: { "roles": { "implementer": "inherit-parent", - "reviewer": "inherit-parent", + "reviewer": ["inherit-parent"], "judge": "inherit-parent", "explorer": "inherit-parent", "synthesizer": "inherit-parent", @@ -56,11 +64,15 @@ Example: } ``` -Use `overrides` only when a Harness needs a different model for the same role. -The adapter reads the selected Harness entry before it reads the role default. +Use `overrides` when a Harness needs a different model for the same role. +The selected Harness entry replaces the role default, including an entire +reviewer list. Select list entries from model names the Harness actually reports; +the default example deliberately names no provider models. ## Verify the result -Read the file after writing it. Check that every selected model is either -available in the current Harness or is `inherit-parent` or `auto`. Report the -roles and selected values to the user. +Read the file after writing it. Check that every changed model is available in +its target Harness or is `inherit-parent` or `auto`. Preserve choices for other +Harnesses when their model catalogs are unavailable. When working in the mstack +repository, run `node scripts/model-config.mjs --file ` to check the shape. +Report the roles and selected values to the user.