Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,30 +181,66 @@ 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:

```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 <known-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 <known-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
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/01-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <known-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

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/10-recipes-and-pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <known-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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion profiles/models.example.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"roles": {
"implementer": "inherit-parent",
"reviewer": "inherit-parent",
"reviewer": ["inherit-parent"],
"judge": "inherit-parent",
"explorer": "inherit-parent",
"synthesizer": "inherit-parent",
Expand Down
12 changes: 6 additions & 6 deletions profiles/upstream-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -97,7 +97,7 @@
},
"interrogate": {
"source": "8aef2c35f2282bbc2636e10b4b52fe41ed5d00c86e775e82fbf8771b2b07f9be",
"target": "8772ecf8ed7e64118459160d4cd61096877b52cc557104a3ae305b036be16ac9"
"target": "ab2508809da00eac47444b102cd7d11730f15b2476cd6cd947e330cc08814e78"
},
"maintain-verification-skill": {
"source": "515c0eaa054b3f6be1b1fb06f2c2f173c80fddb58bbcac57576f89c479bc68e8",
Expand All @@ -109,7 +109,7 @@
},
"meta-mode": {
"source": "9319963075ef537e1d83edc4814f6bc6a7c90779ea261227934c984a0a5e88fb",
"target": "65337fe034b2b34f782f2184e7df15bee535b58918a18efbe969206016c96b1e"
"target": "53cb6ae809072973802f78fac92fc75efbe111330271513fa756cd2f0d053ad4"
},
"no-comments": {
"source": "5c5b0882297d704c3a9720c52b7a793c68b013eaf717989f0945624efdfe2b05",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion profiles/upstreams.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <known-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 <known-parent-model>` to use the chat model."
},
{
"from": "## Install the plugin",
"to": "## Install the skills"
Expand All @@ -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-<app>/`, 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.",
Expand Down
17 changes: 17 additions & 0 deletions scripts/cli-args.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 4 additions & 2 deletions scripts/context.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
Expand Down
4 changes: 4 additions & 0 deletions scripts/environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]) {
Expand All @@ -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);
Expand All @@ -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);
Expand Down
21 changes: 17 additions & 4 deletions scripts/model-config-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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 || "<empty>"} must be a non-empty string`);
}
if (!role.trim()) errors.push("role names must not be empty");
validateRoleValue(role, model, `roles.${role || "<empty>"}`, errors);
}
}
if (config.overrides !== undefined && (!config.overrides || typeof config.overrides !== "object" || Array.isArray(config.overrides))) {
Expand All @@ -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;
Expand Down
Loading