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
5 changes: 5 additions & 0 deletions changes/pstack-t3%2Fmodel-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- kind feature
- `roles.py catalog merge` merges one `orchestrator_capabilities` search result into the saved catalog file so later `show` and `write` see the added models
- `show` reports a `search` list on a role entry when a configured model is missing from a provider that omits models, naming each model to search and merge
- setup searches for a named model the saved catalog lacks, merges the result, and reruns `show` before calling a seat dropped, and never proposes a seat no catalog result contains
- a host whose `orchestrator_capabilities` takes no `search` parameter behaves as before, with no `search` list and the saved catalog as the only source
21 changes: 20 additions & 1 deletion docs/cli/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Roles map a pstack role name to a list of seats. A seat is "inherit" or a target

Two optional keys tune the file. "exclude" is a list of rules that refuse a seat by model-id glob, by declared option, by the provider instance id, by the role name, or by a combination of them. The merged list is the union of the user and project lists. User rules come first. "backups" maps a role to the seats roles.py backup tries, in order, after a usage limit.

`<command>` is one of `show`, `validate`, `write`, `mode`, `check-brief`, `backup`.
`<command>` is one of `show`, `validate`, `write`, `mode`, `check-brief`, `catalog`, `backup`.

## roles.py show

Expand Down Expand Up @@ -108,6 +108,25 @@ roles.py check-brief brief
| --- | --- | --- | --- | --- |
| `brief` | yes | | | brief file to check |

## roles.py catalog

```text
roles.py catalog <catalog_command>
```

`<catalog_command>` is one of `merge`.

## roles.py catalog merge

```text
roles.py catalog merge --catalog CATALOG --from SOURCE
```

| Argument | Required | Choices | Default | Help |
| --- | --- | --- | --- | --- |
| `--catalog CATALOG` | yes | | | saved orchestrator_capabilities JSON file the merge writes back |
| `--from SOURCE` | yes | | | a saved orchestrator_capabilities search result, or - for stdin |

## roles.py backup

```text
Expand Down
22 changes: 21 additions & 1 deletion skills/pstack-runtime/scripts/_roles/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from _roles.mode import effective_mode, light_cap_gap
from _roles.problems import excluded_backup_problems, excluded_problems, validate
from _roles.resolve import resolve
from _roles.seats import exclude_reason, for_runtime_mode, opencode_allow_lines, parse_seat
from _roles.seats import exclude_reason, for_runtime_mode, merge_search, opencode_allow_lines, parse_seat
from _roles import seam


Expand Down Expand Up @@ -287,6 +287,25 @@ def command_write(args):
print(f"wrote {target}")


def command_catalog_merge(args):
"""Merge one search result into the saved catalog file so later show and write see it."""
if str(args.catalog) == "-":
raise RolesError(
"catalog merge: --catalog needs a file, because the merge writes the merged catalog back"
)
saved = load_catalog(args.catalog)
result = load_catalog(args.source)
merged, added = merge_search(saved, result)
if not added:
print("nothing to add")
return 0
for label in added:
print(f"added {label}")
write_atomic(Path(args.catalog), merged)
print(f"wrote {args.catalog}")
return 0


def launch_refusal(detail):
return (
"role 'skill tests' has no seat for a child that launches seats: "
Expand All @@ -305,6 +324,7 @@ def launch_provider(seat, parent):
"back_up",
"load_show_catalog",
"catalog_for_check",
"command_catalog_merge",
"command_show",
"command_validate",
"command_write",
Expand Down
5 changes: 4 additions & 1 deletion skills/pstack-runtime/scripts/_roles/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from _roles.base import BUDGETS, CATALOG_REQUIRED, DefaultSelection, EXCLUDE_RULE, HAIKU_BRIEF, INHERIT, PANEL_BACKUP_ROLE, PANEL_CATALOG_NOTE, ROLES, RUNTIME_MODE_GAP, RolesError, UNSET_NOTE, UNSET_ROLE_NOTE
from _roles.config import DEFAULT_MODE, OpenCodeAllow, allow_for, inherit_parent, stamp_parent
from _roles.mode import seat_budget
from _roles.seats import allow_refuses, apply_budget, check_option_values, effort_option, exclude_reason, find_model, models_of, normalized_bare, opencode_allow_lines, options_of, providers_by_id, runnable, runs_on_cursor
from _roles.seats import allow_refuses, apply_budget, check_option_values, effort_option, exclude_reason, find_model, models_of, normalized_bare, opencode_allow_lines, options_of, providers_by_id, runnable, runs_on_cursor, search_hints
from _roles import seam


Expand Down Expand Up @@ -366,6 +366,9 @@ def resolve(config, catalog=None, names=None, parent=None, launches_seats=False)
lines = opencode_allow_lines({name: list(configured)}, allow_for(config, name), catalog)
if lines:
entry["notes"] = [*entry.get("notes", []), *lines]
hints = search_hints(configured, catalog)
if hints:
entry["search"] = hints
finish_entry(entry, name, catalog, parent, budget, exclude, allow)
result["roles"][name] = entry
return result
Expand Down
70 changes: 70 additions & 0 deletions skills/pstack-runtime/scripts/_roles/seats.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Seat parsing, matching, catalog helpers, levels, cursor, and OpenCode metering."""


import copy
import fnmatch
import re

Expand Down Expand Up @@ -292,6 +293,73 @@ def check_option_values(seat, model):
return problems


def merge_search(saved, result):
"""The saved catalog with the result's models merged in, and the label of each model added.

A result provider merges only when the saved catalog lists it and marks it able to run
children. A model id the saved catalog already has keeps its saved entry and options.
"""
providers = result.get("providers") if isinstance(result, dict) else None
hits = [provider for provider in providers or [] if models_of(provider)]
if not hits:
raise RolesError("search result names no model to merge")
saved_by_id = {
provider.get("providerInstanceId"): provider for provider in (saved.get("providers") or [])
}
for provider in hits:
identity = provider.get("providerInstanceId")
saved_provider = saved_by_id.get(identity)
if saved_provider is None:
raise RolesError(f"search result provider {identity} is not in the saved catalog")
if not saved_provider.get("canRunChildTask"):
detail = "; ".join(saved_provider.get("constraints") or []) or "cannot run child tasks"
raise RolesError(
f"search result provider {identity} is not runnable in the saved catalog ({detail})"
)
merged = copy.deepcopy(saved)
targets = {
provider.get("providerInstanceId"): provider for provider in (merged.get("providers") or [])
}
added = []
for provider in hits:
identity = provider.get("providerInstanceId")
target = targets[identity]
seen = {model.get("id") for model in models_of(target)}
for model in models_of(provider):
if model.get("id") in seen:
continue
seen.add(model.get("id"))
target.setdefault("models", []).append(copy.deepcopy(model))
added.append(f"{identity}/{model.get('id')}")
return merged, added


def search_hints(seats, catalog):
"""The configured seats whose model the saved catalog lacks while their provider omits models."""
if not seats or not catalog:
return []
by_id = {
provider.get("providerInstanceId"): provider for provider in (catalog.get("providers") or [])
}
hints = []
for seat in seats or []:
if not isinstance(seat, dict):
continue
provider = by_id.get(seat.get("providerInstanceId"))
if provider is None or not provider.get("canRunChildTask"):
continue
known = {model.get("id") for model in models_of(provider)}
if seat.get("model") in known:
continue
omitted = provider.get("omittedModels")
if not isinstance(omitted, (int, float)) or isinstance(omitted, bool) or omitted <= 0:
continue
label = f"{seat['providerInstanceId']}/{seat['model']}"
if label not in hints:
hints.append(label)
return hints


def apply_level(seat, model, target_level):
option = reasoning_option(model) if model else None
if target_level is None or option is None:
Expand All @@ -317,6 +385,7 @@ def apply_budget(seat, model, budget):
"bare_id",
"normalized_bare",
"family",
"merge_search",
"model_matches",
"provider_matches",
"exclude_rule_matches",
Expand All @@ -334,6 +403,7 @@ def apply_budget(seat, model, budget):
"models_of",
"options_of",
"runnable",
"search_hints",
"for_runtime_mode",
"find_model",
"rank",
Expand Down
6 changes: 6 additions & 0 deletions skills/pstack-runtime/scripts/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ def main(argv=None):
mode.add_argument("--attempt", choices=ATTEMPTS)
check_brief = sub.add_parser("check-brief")
check_brief.add_argument("brief", help="brief file to check")
catalog = sub.add_parser("catalog")
catalog_commands = catalog.add_subparsers(dest="catalog_command", required=True)
merge = catalog_commands.add_parser("merge")
merge.add_argument("--catalog", required=True, help="saved orchestrator_capabilities JSON file the merge writes back")
merge.add_argument("--from", dest="source", required=True, help="a saved orchestrator_capabilities search result, or - for stdin")
backup = sub.add_parser("backup")
_add_repo_arguments(backup)
backup.add_argument("--catalog", help="saved orchestrator_capabilities JSON. Stdin is the error text, so - is refused")
Expand Down Expand Up @@ -219,6 +224,7 @@ def main(argv=None):
"show": command_show,
"validate": command_validate,
"write": command_write,
"catalog": command_catalog_merge,
"check-brief": command_check_brief,
"mode": command_mode,
"backup": command_backup,
Expand Down
6 changes: 3 additions & 3 deletions skills/setup-pstack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ If `watch_pull_request` is absent, stop setup before writing roles or a saved ca

The runtime host notes state the T3 Code version floor. See [host notes](../pstack-runtime/host.md).

When an expected model is missing, enable Provider update checks in T3's Maintenance settings and refresh provider status. This setting also controls model catalog updates. Read `orchestrator_capabilities` again after the refresh before replacing a seat. Call `orchestrator_capabilities`. Save its JSON result verbatim to a temporary file, for example `/tmp/pstack-t3-catalog.json`. That file is the only source of valid providers, models, and options. Never write a seat that is not in it. Keep the file for the commands in this skill. `show` and `write` both read it, and a user-level `write` saves a copy as the snapshot. A skill that only resolves a role from a tool result uses the quoted heredoc in [Where roles live](../pstack-runtime/SKILL.md#where-roles-live) instead of a file.
When an expected model is missing, enable Provider update checks in T3's Maintenance settings and refresh provider status. This setting also controls model catalog updates. Read `orchestrator_capabilities` again after the refresh before replacing a seat. Call `orchestrator_capabilities`. Save its JSON result verbatim to a temporary file, for example `/tmp/pstack-t3-catalog.json`. That result is a short list. Outside Codex, Claude, Muse, and Grok it holds at most eight models per provider, and each provider's `omittedModels` counts the rest. The tool takes an optional `search` string that returns the same shape with only the matching models. That file with a search result merged into it is the only source of valid providers, models, and options. Never write a seat that is not in it. Keep the file for the commands in this skill. A host whose `orchestrator_capabilities` takes no `search` parameter returns the whole catalog, so a model it lacks is genuinely missing. `show` and `write` both read it, and a user-level `write` saves a copy as the snapshot. A skill that only resolves a role from a tool result uses the quoted heredoc in [Where roles live](../pstack-runtime/SKILL.md#where-roles-live) instead of a file.

List the runnable providers (`canRunChildTask: true`) with their first three models. List the providers that are not runnable with their `constraints`, such as "Provider is not authenticated", so the user knows what to fix in T3 settings. Muse is beta and disabled by default. Its status can come from a cached catalog without a login or model check, so `canRunChildTask: true` does not prove a Muse seat works. Step 5's smoke delegation decides it. Match a Muse instance by `driverKind: "muse"`, not by the ID `muse`.

Expand All @@ -31,7 +31,7 @@ List the runnable providers (`canRunChildTask: true`) with their first three mod
python3 <runtime>/scripts/roles.py show --cwd "$PWD" --catalog /tmp/pstack-t3-catalog.json
```

This prints every role with its seats and `source` (`default`, the user file, or the project file), already resolved against the catalog. It also prints `mode`, `modeSource`, and `escalate`. `notes` name seats that no longer match, such as a model T3 dropped. A saved `contextWindow` on a native Claude 5 seat shows as `dropped unknown options contextWindow`, because T3 Code fixes those models at 1M context. Rewrite that seat without it in step 4.
This prints every role with its seats and `source` (`default`, the user file, or the project file), already resolved against the catalog. It also prints `mode`, `modeSource`, and `escalate`. `notes` name seats that no longer match, such as a model T3 dropped. A note that names a model the saved catalog lacks does not prove T3 dropped it, because step 1's result is a short list. When the host exposes the `search` parameter, call `orchestrator_capabilities` with `search` set to that model id or its name, and save the result to a temporary file. On a host without `search`, keep using the saved catalog and skip search and merge. Merge it with `python3 <runtime>/scripts/roles.py catalog merge --catalog /tmp/pstack-t3-catalog.json --from /tmp/pstack-t3-search.json`. Rerun `show` in this step before calling the seat dropped or rewriting it. Merging a search result with no models exits 2 with `error: search result names no model to merge` and leaves the saved catalog unchanged. An empty result does not prove the model is unavailable. Check the model id and search again before replacing the seat. A merge exits 2 when the result brings a model for a provider the saved catalog lacks or cannot run, and it writes nothing. A role entry that carries a `search` list names configured seats whose models are absent from a provider with omitted models. Each entry is `providerInstanceId/model`. Remove only the provider instance id and the first slash to get the model id. Call `orchestrator_capabilities` with `search` set to that model id, and merge the results the same way. A saved `contextWindow` on a native Claude 5 seat shows as `dropped unknown options contextWindow`, because T3 Code fixes those models at 1M context. Rewrite that seat without it in step 4.

When a configured `claude-opus-5-5`, `claude-sonnet-5-5`, or `claude-haiku-5-5` seat is missing on a native Claude provider, look at T3's model picker for an update notice that names the Claude Code version that model needs. If the notice is there, tell the user to update Claude Code in T3's provider settings and rerun setup. Do not seat a replacement family without saying so.

Expand All @@ -54,7 +54,7 @@ The budget caps the reasoning option (`effort`, `reasoningEffort`, `reasoning_ef

Store the chosen value beside the budget. When the write target is the project file, name the current `escalate` from `show`. `null` means none. Suggest `**/migrations/**` plus the lock, install, and deploy scripts the repository keeps. Pass one `--escalate` per pattern the user keeps. Omitting both escalate flags leaves a stored project list in place. `--clear-escalate` removes it.

**(b) Propose roles.** Reason over the live catalog from step 1 and propose a seat per role. Weigh the role's work. A code role needs a model that writes code well. A judgment or prose role needs a strong reasoner. A read-only role fits a cheaper, high-volume model. Spread a review panel across model families so agreement means something. When the catalog holds no runnable seat from a family other than the author's, the light-mode gate review takes a same-family seat where one is available and writes `same-family` beside the verifier, per [the runtime's Gate review](../pstack-runtime/SKILL.md#gate-review). Tell the user those reviews lose model diversity. Propose `review backups` when the user wants a panel. It has no built-in seats, and it refuses `inherit`, because the parent can be the author. Set at least two seats of different model families, because `write` refuses `review backups` when it keeps fewer than two usable seats, unless `--force` is passed. Start from `roles.py show` in step 2. Keep every configured role unless the user changes it. Any role the user leaves unset resolves to `inherit`, unless the `exclude` list refuses the inherited seat, and `review backups` stays unset. Offer `large` for a new setup. Show the user each role, its proposed seat, and why it fits, and let them change any role. `write` refuses a seat the `exclude` list refuses, even with `--force`, per [the runtime's Excluded seats](../pstack-runtime/SKILL.md#excluded-seats).
**(b) Propose roles.** Reason over the live catalog from step 1 and propose a seat per role. Weigh the role's work. A code role needs a model that writes code well. A judgment or prose role needs a strong reasoner. A read-only role fits a cheaper, high-volume model. Spread a review panel across model families so agreement means something. When the catalog holds no runnable seat from a family other than the author's, the light-mode gate review takes a same-family seat where one is available and writes `same-family` beside the verifier, per [the runtime's Gate review](../pstack-runtime/SKILL.md#gate-review). Tell the user those reviews lose model diversity. Propose `review backups` when the user wants a panel. It has no built-in seats, and it refuses `inherit`, because the parent can be the author. Set at least two seats of different model families, because `write` refuses `review backups` when it keeps fewer than two usable seats, unless `--force` is passed. Start from `roles.py show` in step 2. Keep every configured role unless the user changes it. Any role the user leaves unset resolves to `inherit`, unless the `exclude` list refuses the inherited seat, and `review backups` stays unset. Offer `large` for a new setup. Show the user each role, its proposed seat, and why it fits, and let them change any role. `write` refuses a seat the `exclude` list refuses, even with `--force`, per [the runtime's Excluded seats](../pstack-runtime/SKILL.md#excluded-seats). When the user names a model the saved catalog lacks and the host exposes the `search` parameter, call `orchestrator_capabilities` with `search` set to that model id and merge the result as step 2 says before proposing it. On a host without `search`, keep using the saved catalog. Never propose a seat no catalog result contains.

**(c) Confirm.** Show every role with its seats. Ask whether to accept as-is or change specific roles. For panel roles the seat count is the panel size. `arena cross-judge pool` is also a list, but Arena runs one judge. It tries only seats from a family other than the parent's, in list order, so order it by preference. An `inherit` seat never judges.

Expand Down
Loading
Loading