From 9a318a6d3deed557ddb83457e0b48f388b748319 Mon Sep 17 00:00:00 2001 From: Negrescu Alexandru Date: Fri, 23 Oct 2026 00:50:28 +0300 Subject: [PATCH] feat(setup-pstack): validate and merge model aliases with apply-models.mjs omp config set stores any string, so a bad selector or unknown alias failed only at spawn. The script checks every selector and effort against omp models --json before writing, touches only the three pstack aliases and their agent mappings, and is idempotent. --- .github/workflows/gate.yml | 2 + PORTING.md | 7 + omp-port/check-port.sh | 6 +- omp-port/test-gate-mutations.sh | 8 +- plugins/pstack/skills/setup-pstack/SKILL.md | 16 +- .../setup-pstack/scripts/apply-models.mjs | 150 ++++++++++++++ .../scripts/apply-models.test.mjs | 189 ++++++++++++++++++ 7 files changed, 368 insertions(+), 10 deletions(-) create mode 100755 plugins/pstack/skills/setup-pstack/scripts/apply-models.mjs create mode 100644 plugins/pstack/skills/setup-pstack/scripts/apply-models.test.mjs diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index a77680d..15e16a8 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -36,6 +36,8 @@ jobs: - name: Script tests working-directory: plugins/pstack/skills/poteto-mode/scripts run: bun run test + - name: Setup script tests + run: bun test plugins/pstack/skills/setup-pstack/scripts - name: Script typecheck working-directory: plugins/pstack/skills/poteto-mode/scripts run: bun run typecheck diff --git a/PORTING.md b/PORTING.md index 7469d0c..23b09f9 100644 --- a/PORTING.md +++ b/PORTING.md @@ -251,6 +251,13 @@ you how much upstream moved underneath you. A non-zero count on `skills/setup-ps the selection procedure by hand before trusting the skill; the two other owned paths have no upstream counterpart and always report as port-only. +`skills/setup-pstack/scripts/apply-models.mjs` makes the config write. `omp config set` stores a +selector the catalog does not have and an alias no role defines without complaint, so a bad value +used to surface only at spawn. The script checks every alias, effort, and agent value against +`omp models --json` and the merged `modelRoles` before its first write, preserves every entry pstack +does not own, and converges on a rerun. Its tests run in the gate against a stub `omp` on `PATH`, +and the `setup config contract` check asserts the skill still routes through it. + Known deltas the port mirrors faithfully and will not diverge on. The guide says the verification feature map lives at `references/features` while both trees write `features/`. The guide recommends a daily `/maintain-verification-skill` run while both trees state no cadence. ## The one thing that needed new code diff --git a/omp-port/check-port.sh b/omp-port/check-port.sh index 74cc736..ad1f4ef 100755 --- a/omp-port/check-port.sh +++ b/omp-port/check-port.sh @@ -316,9 +316,7 @@ runtime_contract() { grep -Fxq 'Preserve every unrelated entry in both maps.' "$setup_skill" || bad="$bad"'unrelated setup ownership preservation is missing'$'\n' grep -Fq 'capability, exact discovered agent name' "$setup_skill" || bad="$bad"'setup capability mapping is missing'$'\n' grep -Fq 'The effort ladder is `max > xhigh > high > medium > low > minimal`.' "$setup_skill" || bad="$bad"'setup effort ladder is missing minimal or ordering'$'\n' - grep -Fq 'Strip only that supported suffix and require the remaining base to equal a selector from `omp models --json`.' "$setup_skill" || bad="$bad"'setup suffix or base-selector validation is missing'$'\n' - grep -Fq 'Never strip an arbitrary colon fragment.' "$setup_skill" || bad="$bad"'setup allows arbitrary suffix stripping'$'\n' - grep -Fq '`:low`, or `:minimal` suffix' "$setup_skill" || bad="$bad"'setup supported suffix list is missing minimal'$'\n' + grep -Fq 'APPLY=$(realpath skill://setup-pstack)/scripts/apply-models.mjs' "$setup_skill" && grep -Fq -- '--dry-run' "$setup_skill" && [ -f skills/setup-pstack/scripts/apply-models.mjs ] || bad="$bad"'setup does not plan and write through scripts/apply-models.mjs'$'\n' grep -Fq 'selected model' "$setup_skill" && grep -Fq '`thinking.efforts`' "$setup_skill" || bad="$bad"'setup does not validate supported model efforts'$'\n' grep -Fq 'An omitted override uses the discovered agent' "$setup_skill" && grep -Fq "frontmatter model first, then the parent session's active or default model" "$setup_skill" || bad="$bad"'setup omission precedence is missing'$'\n' grep -Fq 'Family diversity is optional' "$setup_skill" || bad="$bad"'setup family fallback is missing'$'\n' @@ -331,7 +329,7 @@ runtime_contract() { report "setup config contract" "FAIL" while read -r line; do [ -n "$line" ] && violate "$line"; done <<<"$bad" else - report "setup config contract" "PASS ownership, aliases, model efforts, suffix, and base selector" + report "setup config contract" "PASS ownership, aliases, model efforts, suffix, base selector, and the apply-models lever" fi } runtime_contract diff --git a/omp-port/test-gate-mutations.sh b/omp-port/test-gate-mutations.sh index a95772d..1e9e0ec 100755 --- a/omp-port/test-gate-mutations.sh +++ b/omp-port/test-gate-mutations.sh @@ -56,12 +56,15 @@ mutate_alias_shape() { } mutate_supported_suffixes() { - sed -i 's/`:low`, or `:minimal`/`:low`/' "$FIXTURE/skills/setup-pstack/SKILL.md" sed -i 's/, and `minimal`//' "$FIXTURE/skills/omp-mechanics/references/setup-pstack-config.md" } mutate_base_selector() { - sed -i 's/require the remaining base to equal a selector from `omp models --json`/accept any remaining base/' "$FIXTURE/skills/setup-pstack/SKILL.md" + sed -i 's/require the remaining base to equal a selector from `omp models --json`/accept any remaining base/' "$FIXTURE/skills/omp-mechanics/references/setup-pstack-config.md" +} + +mutate_setup_apply_script() { + sed -i '/^APPLY=/d' "$FIXTURE/skills/setup-pstack/SKILL.md" } mutate_setup_lever() { @@ -109,6 +112,7 @@ expect_failure 'per-agent ownership' 'setup config contract' mutate_override_own expect_failure 'alias shape' 'setup config contract' mutate_alias_shape expect_failure 'supported suffix list' 'setup config contract' mutate_supported_suffixes expect_failure 'base selector validation' 'setup config contract' mutate_base_selector +expect_failure 'setup apply script' 'setup config contract' mutate_setup_apply_script expect_failure 'setup model lever' 'omp model levers' mutate_setup_lever expect_failure 'router model lever' 'omp model levers' mutate_router_lever expect_failure 'agent hub api' 'agent hub api' mutate_hub_api diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index ed2f086..71c9184 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -44,17 +44,25 @@ Prefer `ask`. Offer these exact labels and name the current budget when recorded The effort ladder is `max > xhigh > high > medium > low > minimal`. Keep the detected selector as the base. Choose the requested effort only when it appears in that model's `thinking.efforts` list. Otherwise choose the highest listed effort below it. If none exists, mark that alias unresolved. `unlimited` uses the highest listed effort. Never write a requested suffix that the selected model does not support. -### 4. Validate and confirm +### 4. Plan with the script and confirm -For each `modelRoles` value, allow an exact detected selector, or one supported `:max`, `:xhigh`, `:high`, `:medium`, `:low`, or `:minimal` suffix. Strip only that supported suffix and require the remaining base to equal a selector from `omp models --json`. Require the suffix to appear in the selected model's `thinking.efforts` list. Never strip an arbitrary colon fragment. Every `task.agentModelOverrides` value must be an exact detected selector or an `@role` alias already present in the current `modelRoles` map. Show the exact mapping table and ask whether to accept it. Mark unavailable agents and unresolved selectors. Offer only detected selectors, configured aliases, or omission with the exact agent-then-parent resolution above. +`skill://` paths are not executable. Resolve the script once in the bash tool, which expands `skill://` inside `realpath`: + +```bash +APPLY=$(realpath skill://setup-pstack)/scripts/apply-models.mjs +``` + +Turn the table into arguments: `--alias =[:]` per alias, `--agent =@` per mapped agent, and `--unset-agent ` for an agent still bound to a pstack alias but no longer selected. Run `node "$APPLY" --dry-run `; `bun` runs it too. It prints the planned changes, every pstack-owned agent with the model it resolves to, and the family count. Show that output with the table, mark unavailable agents and unresolved selectors, and ask whether to accept it. Offer only detected selectors, configured aliases, or omission with the exact agent-then-parent resolution above. + +The script refuses and writes nothing when an alias is not one of the three, a base is not a selector from `omp models --json`, an effort is off the ladder or missing from the selected model's `thinking.efforts` list, or an agent value is not a `@pstack_*` alias defined in `modelRoles`. It never strips an arbitrary colon fragment. It also refuses to change or remove an agent entry pstack does not own, and any `pstack_*` agent key. Fix the row and rerun the dry run. Never edit `config.yml` by hand to get past a refusal. ### 5. Write the config -Use the shape in `skill://omp-mechanics/references/setup-pstack-config.md`. Write only the three pstack aliases and exact discovered agent keys selected in the table. Update entries currently bound to a pstack alias idempotently, and remove stale entries only when they still point to a pstack alias and their exact agent is no longer selected. Preserve unrelated aliases, overrides, and operator entries. Never write a `pstack_*` agent key. +On acceptance, run the same command without `--dry-run`. It merges onto the current records, writes `modelRoles` before `task.agentModelOverrides`, and preserves every entry pstack does not own. A rerun with the same arguments writes nothing and says so. If it reports that `modelRoles` was written and `task.agentModelOverrides` was not, rerun the same command to finish. ### 6. Confirm -Report the exact aliases and agent keys written, the selectors and effort suffixes used, any unavailable capability, and the family count. State that the entries apply to new sessions and that rerunning updates them. +Report the exact aliases and agent keys written, the selectors and effort suffixes used, any unavailable capability, and the family count from the script's output. One family prints `weaker diversity: one family`; report it as information. State that the entries apply to new sessions and that rerunning updates them. `node "$APPLY" --show` prints the current aliases, agent mappings, and family count. ### 7. Offer a verification skill (optional) diff --git a/plugins/pstack/skills/setup-pstack/scripts/apply-models.mjs b/plugins/pstack/skills/setup-pstack/scripts/apply-models.mjs new file mode 100755 index 0000000..10c11a0 --- /dev/null +++ b/plugins/pstack/skills/setup-pstack/scripts/apply-models.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// Applies setup-pstack's model choices to omp config, checked against `omp models --json` first. +// `omp config set` stores any string, so an unknown selector or alias otherwise fails only at spawn. +// +// Data shape. +// OWNED_ALIASES are the only modelRoles keys this script writes. +// A Selection is { alias, base, effort }: base is a `selector` from the catalog, effort is null or +// an EFFORTS member that the model's `thinking` list names. It is written as base or base:effort. +// A task.agentModelOverrides entry is pstack-owned only when its key is an exact agent name and +// its value is one of OWNED_VALUES. Every other entry in both records keeps its key, value, and order. +import { spawnSync } from "node:child_process"; + +const OWNED_ALIASES = ["pstack_fast_code", "pstack_judgment", "pstack_instruction"]; +const OWNED_VALUES = OWNED_ALIASES.map((alias) => `@${alias}`); +const EFFORTS = ["max", "xhigh", "high", "medium", "low", "minimal"]; +const RECORDS = { roles: "modelRoles", overrides: "task.agentModelOverrides" }; +const USAGE = `usage: apply-models.mjs --show + apply-models.mjs [--dry-run] [--alias =[:effort]]... + [--agent =@]... [--unset-agent ]...`; + +const fail = (message) => { + throw new Error(message); +}; + +function omp(...args) { + const run = spawnSync("omp", args, { encoding: "utf8" }); + if (run.error) fail(`cannot run omp: ${run.error.message}`); + if (run.status !== 0) fail(`omp ${args.slice(0, 3).join(" ")} exited ${run.status}: ${(run.stderr || run.stdout).trim()}`); + return run.stdout; +} + +function parseArgs(argv) { + const request = { show: false, dryRun: false, aliases: new Map(), agents: new Map() }; + const add = (map, key, value) => (map.has(key) ? fail(`${key} is given twice`) : map.set(key, value)); + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const value = () => { + const next = argv[++i]; + return next && !next.startsWith("--") ? next : fail(`${flag} needs a value\n${USAGE}`); + }; + const pair = () => { + const next = value(); + const eq = next.indexOf("="); + return eq > 0 && eq < next.length - 1 ? [next.slice(0, eq), next.slice(eq + 1)] : fail(`${flag} expects =, got "${next}"`); + }; + if (flag === "--show") request.show = true; + else if (flag === "--dry-run") request.dryRun = true; + else if (flag === "--alias") add(request.aliases, ...pair()); + else if (flag === "--agent") add(request.agents, ...pair()); + else if (flag === "--unset-agent") add(request.agents, value(), null); + else fail(`unknown argument "${flag}"\n${USAGE}`); + } + const edits = request.aliases.size + request.agents.size; + if (request.show && (edits || request.dryRun)) fail("--show takes no other argument"); + if (!request.show && !edits) fail(`nothing to do\n${USAGE}`); + return request; +} + +function readCatalog() { + const { models } = JSON.parse(omp("models", "--json")); + return new Map(models.map((model) => [model.selector, Array.isArray(model.thinking) ? model.thinking : []])); +} + +// A value that is itself a selector wins, so a colon inside a selector is never read as an effort. +// Otherwise only a known effort suffix is split off; any other colon fragment stays part of the base. +function parseSelection(alias, value, catalog) { + if (!OWNED_ALIASES.includes(alias)) fail(`--alias ${alias}: not a pstack alias, use one of ${OWNED_ALIASES.join(", ")}`); + if (catalog.has(value)) return { alias, base: value, effort: null }; + const cut = value.lastIndexOf(":"); + const base = value.slice(0, cut); + const effort = value.slice(cut + 1); + const suffixed = cut > 0 && EFFORTS.includes(effort); + if (!suffixed || !catalog.has(base)) fail(`--alias ${alias}=${value}: ${suffixed ? base : value} is not a selector in \`omp models --json\``); + const supported = catalog.get(base); + if (!supported.includes(effort)) fail(`--alias ${alias}=${value}: ${base} supports ${supported.join(", ") || "no effort suffix"}, not ${effort}`); + return { alias, base, effort }; +} + +function plan(current, request, catalog) { + const roles = { ...current.roles }; + for (const [alias, value] of request.aliases) { + const { base, effort } = parseSelection(alias, value, catalog); + roles[alias] = effort ? `${base}:${effort}` : base; + } + const overrides = { ...current.overrides }; + for (const [agent, value] of request.agents) { + const held = overrides[agent]; + if (held !== undefined && !OWNED_VALUES.includes(held)) fail(`${agent} maps to ${JSON.stringify(held)}, which pstack does not own; change it with \`omp config\` yourself`); + if (value === null) { + delete overrides[agent]; + continue; + } + if (agent.startsWith("pstack_")) fail(`--agent ${agent}: a pstack alias is not an agent name`); + if (!OWNED_VALUES.includes(value)) fail(`--agent ${agent}=${value}: use one of ${OWNED_VALUES.join(", ")}`); + const alias = value.slice(1); + if (!Object.hasOwn(roles, alias)) fail(`--agent ${agent}=${value}: modelRoles has no ${alias}, add --alias ${alias}=`); + overrides[agent] = value; + } + return { roles, overrides }; +} + +function describe({ roles, overrides }) { + const lines = OWNED_ALIASES.map((alias) => `${alias} = ${roles[alias] ?? "(unset)"}`); + for (const [agent, value] of Object.entries(overrides)) { + if (OWNED_VALUES.includes(value)) lines.push(`${agent} -> ${value} -> ${roles[value.slice(1)] ?? "(unset)"}`); + } + const families = new Set(OWNED_ALIASES.filter((alias) => roles[alias]).map((alias) => String(roles[alias]).split("/")[0])); + lines.push(`model families: ${families.size}${families.size === 1 ? ", weaker diversity: one family" : ""}`); + return lines.join("\n"); +} + +function changes(before, after, record) { + return [...new Set([...Object.keys(before), ...Object.keys(after)])] + .filter((key) => before[key] !== after[key]) + .map((key) => ` ${record}.${key}: ${before[key] ?? "(unset)"} -> ${after[key] ?? "(removed)"}`); +} + +function main(argv) { + const request = parseArgs(argv); + const current = {}; + for (const [field, record] of Object.entries(RECORDS)) current[field] = JSON.parse(omp("config", "get", record, "--json")).value; + if (request.show) return console.log(describe(current)); + const next = plan(current, request, request.aliases.size ? readCatalog() : new Map()); + const stale = Object.keys(RECORDS).filter((field) => JSON.stringify(current[field]) !== JSON.stringify(next[field])); + if (!stale.length) return console.log(`no change: config already matches\n${describe(next)}`); + const diff = stale.flatMap((field) => changes(current[field], next[field], RECORDS[field])); + console.log([request.dryRun ? "planned changes:" : "changes:", ...diff, describe(next)].join("\n")); + if (request.dryRun) return console.log("dry run: nothing written"); + // modelRoles goes first, so an override never lands pointing at an alias that is not written yet. + for (const [i, field] of stale.entries()) { + try { + omp("config", "set", RECORDS[field], JSON.stringify(next[field])); + } catch (error) { + const landed = stale.slice(0, i).map((done) => RECORDS[done]); + const recovery = landed.length + ? `${landed.join(", ")} was written and ${RECORDS[field]} was not. Rerun the same command to finish; it writes only what still differs.` + : "nothing was written"; + console.error(`apply-models: ${error.message}\n${recovery}`); + process.exit(1); + } + } + console.log(`written: ${stale.map((field) => RECORDS[field]).join(", ")}`); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + console.error(`apply-models: refused: ${error.message}\nnothing was written`); + process.exit(1); +} diff --git a/plugins/pstack/skills/setup-pstack/scripts/apply-models.test.mjs b/plugins/pstack/skills/setup-pstack/scripts/apply-models.test.mjs new file mode 100644 index 0000000..ae5f7cd --- /dev/null +++ b/plugins/pstack/skills/setup-pstack/scripts/apply-models.test.mjs @@ -0,0 +1,189 @@ +import { afterAll, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const SCRIPT = join(import.meta.dir, "apply-models.mjs"); +const BIN = mkdtempSync(join(tmpdir(), "apply-models-")); +const STATE = join(BIN, "state.json"); +const ROLES = "modelRoles"; +const OVERRIDES = "task.agentModelOverrides"; +const MODELS = [ + { selector: "acme/model-a", thinking: ["low", "medium", "high", "xhigh", "max"] }, + { selector: "acme/model-b", thinking: ["low", "medium", "high"] }, + { selector: "acme/model-plain", thinking: null }, + { selector: "relay/acme/model-a:batch", thinking: ["minimal", "low", "high"] }, + { selector: "other/model-c", thinking: ["high"] }, + { selector: "acme/model-odd", thinking: ["high", "turbo"] }, +]; + +// A stand-in for omp's CLI surface the script uses, backed by one JSON file the tests read back. +writeFileSync( + join(BIN, "omp-stub.mjs"), + `import { readFileSync, writeFileSync } from "node:fs"; +const state = JSON.parse(readFileSync(process.env.OMP_STUB_STATE, "utf8")); +const [command, action, key, value] = process.argv.slice(2); +if (command === "models") process.stdout.write(JSON.stringify({ models: state.models })); +else if (command === "config" && action === "get") process.stdout.write(JSON.stringify({ key, value: state.config[key], type: "record" })); +else if (command === "config" && action === "set" && state.failSet !== key) { + state.config[key] = JSON.parse(value); + state.sets += 1; + writeFileSync(process.env.OMP_STUB_STATE, JSON.stringify(state)); +} else { + process.stderr.write("Error: simulated failure"); + process.exit(1); +} +`, +); +writeFileSync(join(BIN, "omp"), `#!/bin/sh\nexec "${process.execPath}" "${join(BIN, "omp-stub.mjs")}" "$@"\n`); +chmodSync(join(BIN, "omp"), 0o755); +afterAll(() => rmSync(BIN, { recursive: true, force: true })); + +function setup(config = {}, extra = {}) { + writeFileSync(STATE, JSON.stringify({ models: MODELS, config: { [ROLES]: {}, [OVERRIDES]: {}, ...config }, sets: 0, ...extra })); +} +const state = () => JSON.parse(readFileSync(STATE, "utf8")); +const ordered = (record) => JSON.stringify(state().config[record]); +function run(...args) { + const env = { ...process.env, PATH: `${BIN}:${process.env.PATH}`, OMP_STUB_STATE: STATE }; + const result = spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8", env }); + return { code: result.status, out: result.stdout, err: result.stderr }; +} +const OPERATOR = { + [ROLES]: { default: "acme/model-b", pstack_judgment: "acme/model-b:low", smol: "@default" }, + [OVERRIDES]: { explore: "acme/model-b", reviewer: "@pstack_judgment", helper: "@smol" }, +}; + +test("writes owned entries and keeps every unrelated key, value, and order", () => { + setup(OPERATOR); + const result = run( + "--alias", "pstack_judgment=acme/model-a:max", + "--alias", "pstack_fast_code=relay/acme/model-a:batch:minimal", + "--alias", "pstack_instruction=relay/acme/model-a:batch", + "--agent", "task=@pstack_fast_code", + "--unset-agent", "reviewer", + ); + expect(result.code).toBe(0); + expect(ordered(ROLES)).toBe( + JSON.stringify({ + default: "acme/model-b", + pstack_judgment: "acme/model-a:max", + smol: "@default", + pstack_fast_code: "relay/acme/model-a:batch:minimal", + pstack_instruction: "relay/acme/model-a:batch", + }), + ); + expect(ordered(OVERRIDES)).toBe(JSON.stringify({ explore: "acme/model-b", helper: "@smol", task: "@pstack_fast_code" })); +}); + +test("refuses a base that is not a catalog selector and writes neither record", () => { + for (const bad of ["acme/model-z:high", "acme/model-z", "acme/model-a:batch", "acme/model-a:"]) { + setup(OPERATOR); + const result = run("--alias", "pstack_fast_code=acme/model-a:high", "--alias", `pstack_judgment=${bad}`, "--agent", "task=@pstack_fast_code"); + expect(result.code).not.toBe(0); + expect(state().config).toEqual(OPERATOR); + } +}); + +test("refuses an effort off the ladder or missing from the selected model's list", () => { + for (const bad of ["acme/model-b:max", "acme/model-plain:low", "other/model-c:minimal", "acme/model-odd:turbo"]) { + setup(OPERATOR); + expect(run("--alias", `pstack_judgment=${bad}`).code).not.toBe(0); + expect(state().config).toEqual(OPERATOR); + } + expect(run("--alias", "pstack_judgment=acme/model-b:high").code).toBe(0); + expect(state().config[ROLES].pstack_judgment).toBe("acme/model-b:high"); +}); + +test("refuses an agent value that is not a defined pstack alias, before any write", () => { + for (const bad of ["@pstack_bogus", "@default", "acme/model-a", "@pstack_instruction"]) { + setup(OPERATOR); + const result = run("--alias", "pstack_fast_code=acme/model-a", "--agent", `task=${bad}`); + expect(result.code).not.toBe(0); + expect(state().config).toEqual(OPERATOR); + } +}); + +test("refuses to change or remove an agent entry pstack does not own", () => { + for (const args of [["--unset-agent", "explore"], ["--agent", "explore=@pstack_judgment"], ["--agent", "helper=@pstack_judgment"]]) { + setup(OPERATOR); + expect(run(...args).code).not.toBe(0); + expect(state().config).toEqual(OPERATOR); + } +}); + +test("a second identical run writes nothing and says so", () => { + setup(OPERATOR); + const args = ["--alias", "pstack_judgment=acme/model-a:high", "--agent", "task=@pstack_judgment", "--unset-agent", "reviewer"]; + expect(run(...args).code).toBe(0); + const after = state(); + expect(after.sets).toBeGreaterThan(0); + const again = run(...args); + expect(again.code).toBe(0); + expect(again.out).toContain("no change"); + expect(state()).toEqual(after); +}); + +test("a failed second write is reported with the rerun that finishes it", () => { + setup(OPERATOR, { failSet: OVERRIDES }); + const args = ["--alias", "pstack_judgment=acme/model-a:high", "--agent", "task=@pstack_judgment"]; + const failed = run(...args); + expect(failed.code).not.toBe(0); + expect(state().config[ROLES].pstack_judgment).toBe("acme/model-a:high"); + expect(state().config[OVERRIDES]).toEqual(OPERATOR[OVERRIDES]); + expect(failed.err).toContain(`${ROLES} was written and ${OVERRIDES} was not`); + expect(failed.err).toContain("Rerun the same command"); + writeFileSync(STATE, JSON.stringify({ ...state(), failSet: null })); + expect(run(...args).code).toBe(0); + expect(state().config[OVERRIDES]).toEqual({ ...OPERATOR[OVERRIDES], task: "@pstack_judgment" }); +}); + +test("one model family is reported as information, never refused", () => { + setup(); + const one = run("--alias", "pstack_fast_code=acme/model-b:low", "--alias", "pstack_judgment=acme/model-a:max", "--alias", "pstack_instruction=acme/model-a"); + expect(one.code).toBe(0); + expect(state().config[ROLES]).toEqual({ pstack_fast_code: "acme/model-b:low", pstack_judgment: "acme/model-a:max", pstack_instruction: "acme/model-a" }); + expect(one.out).toContain("model families: 1, weaker diversity: one family"); + const two = run("--alias", "pstack_judgment=other/model-c:high"); + expect(two.code).toBe(0); + expect(two.out).toContain("model families: 2"); + expect(two.out).not.toContain("weaker diversity"); +}); + +test("--show lists each pstack-owned agent with the model it resolves to", () => { + setup(OPERATOR); + const shown = run("--show"); + expect(shown.code).toBe(0); + expect(shown.out).toMatch(/^reviewer -> @pstack_judgment -> acme\/model-b:low$/m); + expect(shown.out).not.toContain("explore"); + expect(shown.out).not.toContain("helper"); + expect(state().sets).toBe(0); +}); + +test("--dry-run prints the planned change and writes nothing", () => { + setup(OPERATOR); + const planned = run("--dry-run", "--alias", "pstack_judgment=acme/model-a:high", "--agent", "task=@pstack_judgment"); + expect(planned.code).toBe(0); + expect(planned.out).toContain(`${ROLES}.pstack_judgment: acme/model-b:low -> acme/model-a:high`); + expect(planned.out).toContain(`${OVERRIDES}.task: (unset) -> @pstack_judgment`); + expect(state().config).toEqual(OPERATOR); + expect(state().sets).toBe(0); +}); + +test("refuses unknown or malformed arguments", () => { + setup(OPERATOR); + const cases = [ + [], + ["--model", "acme/model-a", "--alias", "pstack_judgment=acme/model-a"], + ["--alias", "pstack_judgment"], + ["--alias", "pstack_other=acme/model-a"], + ["--alias", "pstack_judgment=acme/model-a", "--alias", "pstack_judgment=acme/model-b"], + ["--agent", "pstack_judgment=@pstack_judgment", "--alias", "pstack_judgment=acme/model-a"], + ["--unset-agent", "--dry-run"], + ["--show", "--alias", "pstack_judgment=acme/model-a"], + ]; + for (const args of cases) expect(run(...args).code).not.toBe(0); + expect(state().config).toEqual(OPERATOR); + expect(state().sets).toBe(0); +});