pi-pstack-adapter 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +176 -141
- package/VERSION +1 -1
- package/adapter-skills/setup.md +38 -29
- package/adapter-skills/status.md +3 -3
- package/docs/compatibility.md +46 -23
- package/index.ts +27 -13
- package/package.json +1 -1
- package/src/certified.ts +2 -2
- package/src/config.ts +3 -2
- package/src/gate.ts +3 -2
- package/src/generate.ts +64 -43
- package/src/pools.ts +2 -2
- package/src/registry.ts +9 -9
- package/src/route.ts +318 -0
- package/src/status.ts +21 -14
- package/src/vocabulary.ts +3 -1
package/src/registry.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { CapabilityId, RegistryEntry, SupportTier } from "./types.ts";
|
|
|
7
7
|
*/
|
|
8
8
|
export const SKILL_REGISTRY: Readonly<Record<string, RegistryEntry>> = {
|
|
9
9
|
"architect": { tier: "dependency-gated", capabilities: ["delegation"], executables: [], note: "Phase B delegates to arena runners, so it cannot run without parallel subagents." },
|
|
10
|
-
"arena": { tier: "adapted", capabilities: ["delegation"], executables: ["git"], note: "The whole skill is spawning N candidate subagents plus a judge. Since pstack 0.15.36 the judge must come from a model family other than the parent's. The adapter maps the cross-judge pool to the `arena-cross-judge-pool` panel role, which uses `judgment`, then `general` while unset, so when the parent knows its model, the judge
|
|
10
|
+
"arena": { tier: "adapted", capabilities: ["delegation"], executables: ["git"], note: "The whole skill is spawning N candidate subagents plus a judge. Since pstack 0.15.36 the judge must come from a model family other than the parent's, and since pstack 0.15.42 from another vendor, where Fable and the other Claude models are one vendor. Its default runners and pool are now one Fable, one Opus, and one Gemini model. The adapter maps the cross-judge pool to the `arena-cross-judge-pool` panel role, which uses `judgment`, then `general` while unset, so when the parent knows its model, the judge comes from the first pool entry from another vendor. When no entry qualifies, arena tries `judgment`, then `general`, and only when neither is from another vendor does it skip the judge, pick the base alone, and say so. The status report and `check` warn when that pool is on one model. When the parent does not know its model, arena tries the pool in order anyway and says the vendor check did not run. Verified end to end in Pi on Linux at pstack 0.15.36 with pi-subagents: two candidates on the `judgment` and `general` roles, then a judge from the family other than the parent's, which scored them. The parent picked a base, took the fixes for two bugs from the other candidate, fixed a third that both shared, and finished with passing tests. It left the candidate worktrees for comparison. The pstack 0.15.42 vendor rule has not run in Pi." },
|
|
11
11
|
"automate-me": { tier: "experimental", capabilities: ["delegation", "structured-question"], executables: ["git", "gh"], note: "Its mining step reads this workspace's transcripts. The host mapping points it at this workspace's Pi session files, which use a different format. Verified end to end in Pi on Linux at pstack 0.15.36: it read only this workspace's sessions, told the parent chat from its subagents' files, found no stated preference there, and changed nothing. The built-in create-skill skill it drafts with has no Pi equivalent, and that step has not run in Pi." },
|
|
12
12
|
"benchmark-checklist": { tier: "dependency-gated", capabilities: [], executables: ["uptime", "nproc"], note: "Its setup step checks load with `uptime` and cores with `nproc`. macOS has no `nproc` without GNU coreutils, and Git Bash on Windows has no `uptime`." },
|
|
13
13
|
"blast-radius": { tier: "dependency-gated", capabilities: [], executables: ["git", "gh"], note: "Needs git and gh to read the diff, commits, and PR before any analysis." },
|
|
@@ -16,7 +16,7 @@ export const SKILL_REGISTRY: Readonly<Record<string, RegistryEntry>> = {
|
|
|
16
16
|
"create-formal-verification-skill": { tier: "adapted", capabilities: [], executables: [], note: "Manual invocation only; no delegation. It installs or runs the verification tool it chooses and temporarily breaks product code to prove the check. Generated skills belong under `.pi/skills/` in Pi. Verified on a Windows Node finite-state fixture; other toolchains need their own prerequisites. Since pstack 0.15.32, a generated TLA+ check tells the next agent to read its results with statechart, which has its own prerequisites." },
|
|
17
17
|
"create-verification-skill": { tier: "adapted", capabilities: [], executables: [], note: "Only the generated skill's output path needs remapping to Pi's skill directory." },
|
|
18
18
|
"figure-it-out": { tier: "dependency-gated", capabilities: ["delegation"], executables: [], note: "Phase B fan-out and the architect/arena routing require parallel subagents." },
|
|
19
|
-
"how": { tier: "dependency-gated", capabilities: ["delegation"], executables: [], note: "
|
|
19
|
+
"how": { tier: "dependency-gated", capabilities: ["delegation"], executables: [], note: "Since pstack 0.15.42 it spawns one read-only subagent that explores and explains in one pass, and nothing runs inline. That path has not run in Pi." },
|
|
20
20
|
"in-your-own-words": { tier: "native", capabilities: [], executables: [] },
|
|
21
21
|
"interrogate": { tier: "adapted", capabilities: ["delegation"], executables: ["git"], note: "Multi-model adversarial review is entirely one subagent per configured model. Since pstack 0.15.36 it picks adversaries the way arena picks runners, from the `interrogate reviewers` line, and gives each one its own worktree with the scope's uncommitted changes. Each adversary must prove a finding with a run, and the skill keeps only findings whose command it reruns and sees fail. It reports findings and applies no fixes, but adversaries write and run repro commands, and the skill reruns them, all with your permissions. A worktree is not a sandbox, so read each repro before it reruns, especially on code you do not trust. The rubric, the lead's judgment, and the code-quality lens are gone. Verified end to end in Pi on Linux at pstack 0.15.36 with pi-subagents: two adversaries on the `judgment` and `general` roles, from two model families, each in its own worktree, proved the same two real bugs, and the skill reran both repros before it reported them. It left the adversary worktrees in the temporary folder and named the commands that remove them." },
|
|
22
22
|
"ketchup": { tier: "adapted", capabilities: [], executables: [], note: "Read-only. Pi has no `/visualize` skill or image generation, so its visuals are Mermaid diagrams and screenshots that already exist. It checks PR and CI state only for work the chat touched." },
|
|
@@ -26,7 +26,7 @@ export const SKILL_REGISTRY: Readonly<Record<string, RegistryEntry>> = {
|
|
|
26
26
|
"make-bot-ui": { tier: "unsupported", capabilities: [], executables: ["curl", "tailscale", "sudo"], note: "Built entirely on Cursor Automations webhook routines and the secret-request card." },
|
|
27
27
|
"no-comments": { tier: "dependency-gated", capabilities: ["delegation"], executables: ["git"], note: "Step 1 must spawn the comment-sicko agent; the skill only triages its report." },
|
|
28
28
|
"poteto-help": { tier: "experimental", capabilities: [], executables: [], note: "Its answers describe Cursor setup, the slash menu, and Custom Modes. A request for work runs poteto-mode, which is experimental. It asks a multiple-choice question only when the need is unclear. Its README and guide links resolve under the status report's plugin directory." },
|
|
29
|
-
"poteto-mode": { tier: "experimental", capabilities: ["delegation", "structured-question"], executables: ["git", "gh"], note: "Sticky-mode hosting plus references to Cursor built-in and plugin skills only partially map to Pi. Since pstack 0.15.36 the Orchestrate playbook calls no scripts, because the `orch` helpers are gone, and watches PRs through event subscriptions only when the harness has them. Pi has none. Its Project tracker and Project store, and forking a worker with its built state, have no Pi equivalent; the host mapping says what to do instead. Verified end to end in Pi on Linux at pstack 0.15.36 with pi-subagents and a question tool: Orchestrate ran a two-unit program in a linked worktree, with an owner per unit on its own branch and worktree, a verifier from another model family at the owner's head, and local merges. It kept the tracker and store in a folder it added to the exclude file Git resolved for the worktree, and handed state to new agents instead of messaging old ones. Its other playbooks and sticky mode were not rerun." },
|
|
29
|
+
"poteto-mode": { tier: "experimental", capabilities: ["delegation", "structured-question"], executables: ["git", "gh"], note: "Sticky-mode hosting plus references to Cursor built-in and plugin skills only partially map to Pi. Since pstack 0.15.36 the Orchestrate playbook calls no scripts, because the `orch` helpers are gone, and watches PRs through event subscriptions only when the harness has them. Pi standalone has none; a T3 Code host can provide them when it exposes `mcp__t3-code__watch_pull_request`. This is a host capability, not a guarantee of either delegation backend. Its Project tracker and Project store, and forking a worker with its built state, have no Pi equivalent; the host mapping says what to do instead. Verified end to end in Pi on Linux at pstack 0.15.36 with pi-subagents and a question tool: Orchestrate ran a two-unit program in a linked worktree, with an owner per unit on its own branch and worktree, a verifier from another model family at the owner's head, and local merges. It kept the tracker and store in a folder it added to the exclude file Git resolved for the worktree, and handed state to new agents instead of messaging old ones. Its other playbooks and sticky mode were not rerun. Since pstack 0.15.42 it tells the user to rerun setup when the model rule has no `# defaults: 2` line. The host mapping says that check does not apply to the adapter configuration." },
|
|
30
30
|
"principle-attack-the-premise": { tier: "native", capabilities: [], executables: [] },
|
|
31
31
|
"principle-boundary-discipline": { tier: "native", capabilities: [], executables: [] },
|
|
32
32
|
"principle-build-the-lever": { tier: "native", capabilities: [], executables: [] },
|
|
@@ -51,19 +51,19 @@ export const SKILL_REGISTRY: Readonly<Record<string, RegistryEntry>> = {
|
|
|
51
51
|
"principle-subtract-before-you-add": { tier: "native", capabilities: [], executables: [] },
|
|
52
52
|
"principle-test-behavior-not-implementation": { tier: "native", capabilities: [], executables: [] },
|
|
53
53
|
"principle-type-system-discipline": { tier: "native", capabilities: [], executables: [] },
|
|
54
|
-
"recall": { tier: "dependency-gated", capabilities: ["delegation"], executables: ["git", "gh"], note: "Its core corpus is Cursor's on-disk transcript JSONL. The host mapping points it at this workspace's Pi session files instead. Verified end to end in Pi on Linux at pstack 0.15.36: in a fresh session it found the two earlier chats in the folder that holds `$PI_SESSION_FILE`, read their user messages, checked their claims against git and the code, and rebuilt the threads, problems, and next move. With two chats to read, it read them itself, so it did not fan out to subagents, and that step has not run in Pi. A run before the host mapping said to start from `$PI_SESSION_FILE` looked in the default folder instead, which a custom `PI_CODING_AGENT_DIR` had moved, and found nothing." },
|
|
54
|
+
"recall": { tier: "dependency-gated", capabilities: ["delegation"], executables: ["git", "gh"], note: "Its core corpus is Cursor's on-disk transcript JSONL. The host mapping points it at this workspace's Pi session files instead. Verified end to end in Pi on Linux at pstack 0.15.36: in a fresh session it found the two earlier chats in the folder that holds `$PI_SESSION_FILE`, read their user messages, checked their claims against git and the code, and rebuilt the threads, problems, and next move. With two chats to read, it read them itself, so it did not fan out to subagents, and that step has not run in Pi. A run before the host mapping said to start from `$PI_SESSION_FILE` looked in the default folder instead, which a custom `PI_CODING_AGENT_DIR` had moved, and found nothing. Since pstack 0.15.42 its shared-record sweep spawns one investigator per source itself instead of reusing why's investigators, and that step has not run in Pi." },
|
|
55
55
|
"reflect": { tier: "experimental", capabilities: ["delegation"], executables: [], note: "Its reviewers read the active transcript, and the host mapping points them at the current Pi session file. Verified end to end in Pi on Linux at pstack 0.15.36 with pi-subagents: three reviewers on two model families and a synthesizer read the session file, and the run found a regression the earlier review missed. When told to apply two edits, it copied the upstream skill from the plugin directory to a Pi skill location under a new name and edited only the copy. Its create-skill routing for a new skill is Cursor-specific and has not run in Pi. It never opens a PR or pushes." },
|
|
56
56
|
"setup-pstack": { tier: "native", capabilities: [], executables: [], rename: "setup", replacement: "setup.md", note: "The upstream body only writes a Cursor .mdc rule, so the adapter replaces it with a Pi setup workflow." },
|
|
57
|
-
"sharpen": { tier: "native", capabilities: [], executables: [], note: "Verified end to end in Pi on Linux at pstack 0.15.36: it handed back four short sections and a rewritten prompt for a vague gateway task, left the decisions that were the user's open, and did not start the task. Since pstack 0.15.29 it hands back a rewritten prompt and does not do the task. Its body is prompt-only, with no tool, delegation, or executable, and it runs only when you type it." },
|
|
57
|
+
"sharpen": { tier: "native", capabilities: [], executables: [], note: "Verified end to end in Pi on Linux at pstack 0.15.36: it handed back four short sections and a rewritten prompt for a vague gateway task, left the decisions that were the user's open, and did not start the task. Since pstack 0.15.29 it hands back a rewritten prompt and does not do the task. Its body is prompt-only, with no tool, delegation, or executable, and it runs only when you type it. Since pstack 0.15.42 the rewritten prompt asks the agent to prototype any choice an experiment can settle and leaves only product or preference choices to the user. That wording has not been rerun in Pi." },
|
|
58
58
|
"show-me-your-work": { tier: "adapted", capabilities: ["delegation"], executables: [], note: "The mandatory cross-model review of the trail requires spawning a different-family subagent. Verified end to end in Pi on Linux at pstack 0.15.36 with pi-subagents: it kept a decision log the repository ignores, and its reviewer on another model family read the parent's Pi session file and checked each row against it. Its own scripts ran unchanged." },
|
|
59
|
-
"statechart": { tier: "experimental", capabilities: [], executables: ["node", "sh", "dirname", "sed"], note: "Lowered from dependency-gated at pstack 0.15.34, because its new scripts crash on native Windows, a certified platform. There, the launcher starts `/bin/sh` by that path to stop TLC's process group, Node cannot start it even when `sh` is on PATH, and with no error handler the run exits 1 with `spawn /bin/sh ENOENT`. It writes no output and leaves an empty hidden staging folder beside the output folder and an empty `statechart-` folder in the temporary folder, so delete those by hand. `/pistack-check` still clears it there, because the gates are found. Use it from Linux, macOS, or WSL. Verified end to end on a Linux checkout fixture at pstack 0.15.34, and again at pstack 0.15.36, where it found the uncapped refill in a token bucket as the shortest path to a broken rule. The Windows end-to-end evidence from pstack 0.15.32 and 0.15.30 describes the scripts it replaced. Manual invocation only; no delegation or structured question. `scripts/statechart` is a POSIX `sh` launcher for TypeScript scripts, and it runs `dirname` and `sed` on every start. It runs the scripts under Node 22.18 to 22.x or Node 23.6 or later, then Bun 1.1.5 or later, then Deno 2.0 or later, and exits with code 127 when none is found. The prerequisite check gates on `node` but cannot check its version. The launcher does: with only an older Node on PATH, it names the versions it needs and exits 127. The command checks a TLA+ spec with TLC and writes `report.md` and TLC output to a new or empty `--out` folder, `SPEC-chart` beside the spec by default, and refuses a folder that is not empty. It also writes `chart.mmd`, except when TLC finds more than 500 states and no rule breaks. It stages its work in a hidden folder beside the output folder and in the system temporary folder, and deletes both. TLC runs from `TLA2TOOLS`, the path to a downloaded `tla2tools.jar`, with the binary in `JAVA` or `java` from PATH. Without `TLA2TOOLS`, it uses TLC 1.7.4 and Java temurin-25 from `mise`, and installs whichever is missing with `mise`, which needs the network. `JAVA` then changes only the binary that runs TLC, and does not skip the `mise` lookup or install of Java. Either `mise` or `TLA2TOOLS` with Java works, so neither is gated, and the script names what is missing when neither is set up. It imports the `--view` module as JavaScript with full process permissions, so review an existing view before you run it. Since pstack 0.15.34 the scripts render no images and do not run `npm`. For an image, the body has the agent install `pele@1.9.0` with `npm` once, outside the repository, which needs `npm` and the network, and render SVG with it. The body does not ask for `--ignore-scripts`, so pass it, and rendering still imports Pele with full process permissions. Run it in a scratch folder outside the repository. Git for Windows bundles `sh`, `dirname`, and `sed`, but not `node`, `mise`, or Java." },
|
|
59
|
+
"statechart": { tier: "experimental", capabilities: [], executables: ["node", "sh", "dirname", "sed"], note: "Lowered from dependency-gated at pstack 0.15.34, because its new scripts crash on native Windows, a certified platform. There, the launcher starts `/bin/sh` by that path to stop TLC's process group, Node cannot start it even when `sh` is on PATH, and with no error handler the run exits 1 with `spawn /bin/sh ENOENT`. It writes no output and leaves an empty hidden staging folder beside the output folder and an empty `statechart-` folder in the temporary folder, so delete those by hand. `/pistack-check` still clears it there, because the gates are found. Use it from Linux, macOS, or WSL. Verified end to end on a Linux checkout fixture at pstack 0.15.34, and again at pstack 0.15.36, where it found the uncapped refill in a token bucket as the shortest path to a broken rule. The Windows end-to-end evidence from pstack 0.15.32 and 0.15.30 describes the scripts it replaced. Manual invocation only; no delegation or structured question. `scripts/statechart` is a POSIX `sh` launcher for TypeScript scripts, and it runs `dirname` and `sed` on every start. It runs the scripts under Node 22.18 to 22.x or Node 23.6 or later, then Bun 1.1.5 or later, then Deno 2.0 or later, and exits with code 127 when none is found. The prerequisite check gates on `node` but cannot check its version. The launcher does: with only an older Node on PATH, it names the versions it needs and exits 127. The command checks a TLA+ spec with TLC and writes `report.md` and TLC output to a new or empty `--out` folder, `SPEC-chart` beside the spec by default, and refuses a folder that is not empty. It also writes `chart.mmd`, except when TLC finds more than 500 states and no rule breaks. It stages its work in a hidden folder beside the output folder and in the system temporary folder, and deletes both. TLC runs from `TLA2TOOLS`, the path to a downloaded `tla2tools.jar`, with the binary in `JAVA` or `java` from PATH. Without `TLA2TOOLS`, it uses TLC 1.7.4 and Java temurin-25 from `mise`, and installs whichever is missing with `mise`, which needs the network. `JAVA` then changes only the binary that runs TLC, and does not skip the `mise` lookup or install of Java. Either `mise` or `TLA2TOOLS` with Java works, so neither is gated, and the script names what is missing when neither is set up. It imports the `--view` module as JavaScript with full process permissions, so review an existing view before you run it. Since pstack 0.15.34 the scripts render no images and do not run `npm`. For an image, the body has the agent install `pele@1.9.0` with `npm` once, outside the repository, which needs `npm` and the network, and render SVG with it. The body does not ask for `--ignore-scripts`, so pass it, and rendering still imports Pele with full process permissions. Run it in a scratch folder outside the repository. Git for Windows bundles `sh`, `dirname`, and `sed`, but not `node`, `mise`, or Java. At pstack 0.15.42 the Mermaid backend can draw the edges into a nested state as one edge into its parent plus start edges inside it, and a SANY parse failure reruns SANY to print its errors. Those script changes add no executable, leave the `/bin/sh` launcher unchanged, and have not run in Pi." },
|
|
60
60
|
"swarm": { tier: "experimental", capabilities: ["delegation"], executables: [], note: "Cloud background workers and cloud_base_branch have no Pi mapping; only local fan-out approximates it." },
|
|
61
61
|
"tdd": { tier: "native", capabilities: [], executables: [] },
|
|
62
|
-
"teach": { tier: "dependency-gated", capabilities: ["delegation"], executables: [], note: "It runs the how and why
|
|
62
|
+
"teach": { tier: "dependency-gated", capabilities: ["delegation"], executables: [], note: "It runs the how skill, which needs a subagent, and the why skill, which since pstack 0.15.42 runs inline and needs git and gh. Check why's prerequisites too." },
|
|
63
63
|
"technical-writing": { tier: "native", capabilities: [], executables: [] },
|
|
64
64
|
"typescript-best-practices": { tier: "native", capabilities: [], executables: [] },
|
|
65
65
|
"unslop": { tier: "native", capabilities: [], executables: [] },
|
|
66
|
-
"why": { tier: "dependency-gated", capabilities: [
|
|
66
|
+
"why": { tier: "dependency-gated", capabilities: [], executables: ["git", "gh"], note: "Since pstack 0.15.42 it spawns no subagents. It answers inline from the commits and PR discussions behind the target, and from other sources when that trail is thin, so it needs git, and gh for the PR discussions. The inline path has not run in Pi." },
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
/** Adapter-owned skills with no upstream counterpart. Keyed by unprefixed generated name. */
|
|
@@ -113,7 +113,7 @@ export const MODEL_ROLES: readonly string[] = [
|
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Upstream's panel lines, such as `arena runners`, hold a list of models with one seat per entry.
|
|
116
|
-
* Upstream defaults each to
|
|
116
|
+
* Upstream defaults each to several model families from two vendors. A panel left unset here uses the roles in
|
|
117
117
|
* `PANEL_DEFAULT_ROLES`, so it spans two families only when those roles do.
|
|
118
118
|
*/
|
|
119
119
|
export const PANEL_ROLES: readonly string[] = [
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AdapterConfig,
|
|
3
|
+
type ModelSpec,
|
|
4
|
+
type ThinkingLevel,
|
|
5
|
+
THINKING_LEVELS,
|
|
6
|
+
isThinkingLevel,
|
|
7
|
+
isValidModelSpec,
|
|
8
|
+
isValidRole,
|
|
9
|
+
joinModelSpec,
|
|
10
|
+
splitModelSpec,
|
|
11
|
+
} from "./config.ts";
|
|
12
|
+
import { SUBAGENT_TOOL, T3_DELEGATE_TOOL } from "./delegation.ts";
|
|
13
|
+
import { safeEcho } from "./echo.ts";
|
|
14
|
+
import type { ModelFilter } from "./filter.ts";
|
|
15
|
+
import { resolveRole } from "./pools.ts";
|
|
16
|
+
import { MODEL_ROLES, PANEL_DEFAULT_ROLES, isPanelRole } from "./registry.ts";
|
|
17
|
+
|
|
18
|
+
/** A model string with this prefix names a pstack role, and the adapter picks the model. */
|
|
19
|
+
export const ROLE_REFERENCE_PREFIX = "pstack:";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A delegation's model string. `pstack:<role>` picks by the role's ranking, `pstack:<role>@<model>`
|
|
23
|
+
* overrides it, and either may end in `:<level>` to lower the thinking level. Any other string is a
|
|
24
|
+
* concrete model, which only the model filter checks.
|
|
25
|
+
*/
|
|
26
|
+
export type ModelRequest =
|
|
27
|
+
| { readonly kind: "concrete" }
|
|
28
|
+
| { readonly kind: "role"; readonly text: string; readonly role: string; readonly override?: string; readonly thinking?: ThinkingLevel }
|
|
29
|
+
| { readonly kind: "invalid"; readonly text: string; readonly problem: string };
|
|
30
|
+
|
|
31
|
+
export type RoleReference = Extract<ModelRequest, { kind: "role" }>;
|
|
32
|
+
|
|
33
|
+
export function parseModelRequest(value: unknown): ModelRequest {
|
|
34
|
+
if (typeof value !== "string") return { kind: "concrete" };
|
|
35
|
+
const text = value.trim();
|
|
36
|
+
if (!text.startsWith(ROLE_REFERENCE_PREFIX)) return { kind: "concrete" };
|
|
37
|
+
const { model: body, thinking } = splitModelSpec(text);
|
|
38
|
+
const rest = body.slice(ROLE_REFERENCE_PREFIX.length);
|
|
39
|
+
const at = rest.indexOf("@");
|
|
40
|
+
const role = at < 0 ? rest : rest.slice(0, at);
|
|
41
|
+
const override = at < 0 ? undefined : rest.slice(at + 1);
|
|
42
|
+
const shape = "Write `pstack:<role>`, optionally followed by `@<provider/model>` and then `:<level>`, for example `pstack:feature` or `pstack:feature@provider/model:high`.";
|
|
43
|
+
if (!isValidRole(role)) return { kind: "invalid", text, problem: `it names no valid role. ${shape}` };
|
|
44
|
+
if (override !== undefined && (override === "" || !isValidModelSpec(override) || splitModelSpec(override).thinking !== undefined)) {
|
|
45
|
+
return { kind: "invalid", text, problem: `its override after \`@\` is not one \`provider/model\` id. ${shape}` };
|
|
46
|
+
}
|
|
47
|
+
return { kind: "role", text, role, ...(override !== undefined ? { override } : {}), ...(thinking !== undefined ? { thinking } : {}) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a pick strategy sees. `choices` holds only the models the filter allows, in ranked order,
|
|
52
|
+
* and `task` is the text the child receives.
|
|
53
|
+
*/
|
|
54
|
+
export interface PickRequest {
|
|
55
|
+
readonly role: string;
|
|
56
|
+
readonly choices: readonly ModelSpec[];
|
|
57
|
+
readonly task: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The seam for choosing a pool model. It returns an index into `request.choices`; ranked takes the
|
|
62
|
+
* first. It may return a promise, so a strategy that asks a service can replace it.
|
|
63
|
+
*/
|
|
64
|
+
export function pickModel(_request: PickRequest): number | Promise<number> {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The choice a pick returned, or undefined when the index is not one of the choices. */
|
|
69
|
+
export function validatePick(index: number, choices: readonly ModelSpec[]): ModelSpec | undefined {
|
|
70
|
+
return Number.isInteger(index) && index >= 0 && index < choices.length ? choices[index] : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The thinking level a child runs at. A requested level can only lower the configured one, and an
|
|
75
|
+
* entry with no configured level keeps the backend default whatever was requested.
|
|
76
|
+
*/
|
|
77
|
+
export function effectiveThinking(requested: ThinkingLevel | undefined, configured: ThinkingLevel | undefined): ThinkingLevel | undefined {
|
|
78
|
+
if (configured === undefined) return undefined;
|
|
79
|
+
if (requested === undefined) return configured;
|
|
80
|
+
return lowest(requested, configured);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lowest(a: ThinkingLevel | undefined, b: ThinkingLevel | undefined): ThinkingLevel | undefined {
|
|
84
|
+
if (a === undefined) return b;
|
|
85
|
+
if (b === undefined) return a;
|
|
86
|
+
return THINKING_LEVELS.indexOf(a) <= THINKING_LEVELS.indexOf(b) ? a : b;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RouteContext {
|
|
90
|
+
/** Undefined when the adapter did not start, so no role can be resolved. */
|
|
91
|
+
readonly config: AdapterConfig | undefined;
|
|
92
|
+
readonly filter?: ModelFilter;
|
|
93
|
+
/** `provider/id` of the session's model. */
|
|
94
|
+
readonly parentModel?: string;
|
|
95
|
+
/** The session's thinking level, which an inheriting `subagent` child keeps. */
|
|
96
|
+
readonly parentThinking?: ThinkingLevel;
|
|
97
|
+
/** Maps a model to its registry `provider/id`, or undefined when Pi does not know it. */
|
|
98
|
+
readonly resolveModel: (model: string) => string | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type RouteDecision =
|
|
102
|
+
| { readonly kind: "unchanged" }
|
|
103
|
+
| { readonly kind: "rewrite"; readonly input: Record<string, unknown> }
|
|
104
|
+
| { readonly kind: "block"; readonly reason: string };
|
|
105
|
+
|
|
106
|
+
type ChildRoute =
|
|
107
|
+
| { readonly kind: "model"; readonly spec: ModelSpec }
|
|
108
|
+
| { readonly kind: "inherit" }
|
|
109
|
+
| { readonly kind: "block"; readonly reason: string };
|
|
110
|
+
|
|
111
|
+
const UNCHANGED: RouteDecision = { kind: "unchanged" };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replaces each role reference in a `delegate_task` or `subagent` call with the model and thinking
|
|
115
|
+
* the adapter picks. The result is a rewritten copy; concrete models and other tools are unchanged.
|
|
116
|
+
*/
|
|
117
|
+
export async function routeDelegation(toolName: string, input: unknown, context: RouteContext): Promise<RouteDecision> {
|
|
118
|
+
if (!isRecord(input)) return UNCHANGED;
|
|
119
|
+
if (toolName === T3_DELEGATE_TOOL) return routeT3(input, context);
|
|
120
|
+
if (toolName === SUBAGENT_TOOL) return routeSubagent(input, context);
|
|
121
|
+
return UNCHANGED;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Makes `target` hold exactly the keys of `source`, because Pi runs the tool on the object it passed. */
|
|
125
|
+
export function applyRewrite(target: Record<string, unknown>, source: Readonly<Record<string, unknown>>): void {
|
|
126
|
+
for (const key of Object.keys(target)) if (!Object.hasOwn(source, key)) delete target[key];
|
|
127
|
+
Object.assign(target, source);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function blockReason(where: string, text: string, problem: string): string {
|
|
131
|
+
return `Blocked by the pstack adapter: ${where} asks for ${text}, but ${problem} This call was not retried.`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A parsed reference holds only role and model-id characters; anything else is echoed safely. */
|
|
135
|
+
function quoted(request: Exclude<ModelRequest, { kind: "concrete" }>): string {
|
|
136
|
+
return request.kind === "role" ? `\`${request.text}\`` : safeEcho(request.text);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function routeReference(reference: RoleReference, where: string, requested: ThinkingLevel | undefined, task: string, context: RouteContext): Promise<ChildRoute> {
|
|
140
|
+
const { role } = reference;
|
|
141
|
+
const block = (problem: string): ChildRoute => ({ kind: "block", reason: blockReason(where, quoted(reference), problem) });
|
|
142
|
+
if (context.config === undefined) {
|
|
143
|
+
return block(`the adapter did not start, so it cannot resolve role \`${role}\`. Run \`pstack_adapter\` with \`action: "status"\` to see why, fix the problem, and reload.`);
|
|
144
|
+
}
|
|
145
|
+
if (isPanelRole(role)) {
|
|
146
|
+
return block(
|
|
147
|
+
`\`${role}\` is a panel role, which holds one seat per entry rather than one model. Start one child per seat as the \`panel roles\` table shows: an unset panel's seats are ${PANEL_DEFAULT_ROLES.map((seat) => `\`pstack:${seat}\``).join(" and ")}, and a configured seat names its own model.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (!MODEL_ROLES.includes(role) && !Object.hasOwn(context.config.models, role)) {
|
|
151
|
+
return block(`\`${role}\` is not a pstack role. Use a role from the \`model roles\` table of the \`pstack_adapter\` status report, or \`pstack:general\` when no other role fits.`);
|
|
152
|
+
}
|
|
153
|
+
const resolution = resolveRole(context.config, role, { filter: context.filter, parentModel: context.parentModel });
|
|
154
|
+
switch (resolution.kind) {
|
|
155
|
+
case "blocked":
|
|
156
|
+
return block(`role \`${role}\` is blocked: ${resolution.reason}. Do not delegate this role's work, and do not move it to \`general\` or your own model. Tell the user which role is blocked and why.`);
|
|
157
|
+
case "inherit":
|
|
158
|
+
if (reference.override !== undefined) {
|
|
159
|
+
return block(`role \`${role}\` has no pool to override, because it inherits the parent's model. Pass \`pstack:${role}\` without \`@\`.`);
|
|
160
|
+
}
|
|
161
|
+
return { kind: "inherit" };
|
|
162
|
+
case "pool": {
|
|
163
|
+
const choices = resolution.choices.filter((choice) => choice.eligible).map((choice) => choice.spec);
|
|
164
|
+
let chosen: ModelSpec | undefined;
|
|
165
|
+
if (reference.override !== undefined) {
|
|
166
|
+
const allowed = `Its allowed entries are ${choices.map((choice) => `\`${choice.model}\``).join(", ")}. Override with one of them, or pass \`pstack:${role}\` for the ranked pick.`;
|
|
167
|
+
const wanted = context.resolveModel(reference.override);
|
|
168
|
+
if (wanted === undefined) return block(`this Pi installation does not know \`${reference.override}\`. ${allowed}`);
|
|
169
|
+
chosen = choices.find((choice) => (context.resolveModel(choice.model) ?? choice.model) === wanted);
|
|
170
|
+
if (chosen === undefined) return block(`\`${wanted}\` is not an entry of role \`${role}\`'s pool that the model filter allows. ${allowed}`);
|
|
171
|
+
} else {
|
|
172
|
+
const index = await pickModel({ role, choices, task });
|
|
173
|
+
chosen = validatePick(index, choices);
|
|
174
|
+
if (chosen === undefined) {
|
|
175
|
+
return block(`the adapter's model pick for role \`${role}\` returned index ${index}, which is not one of its ${choices.length} allowed models. This is an adapter bug; report it to the user.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const thinking = effectiveThinking(requested, chosen.thinking);
|
|
179
|
+
return { kind: "model", spec: { model: chosen.model, ...(thinking !== undefined ? { thinking } : {}) } };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A T3 target names a Pi model, so a role reference only works with the `pi` provider. */
|
|
185
|
+
async function routeT3(args: Record<string, unknown>, context: RouteContext): Promise<RouteDecision> {
|
|
186
|
+
const target = args.target;
|
|
187
|
+
if (!isRecord(target)) return UNCHANGED;
|
|
188
|
+
const request = parseModelRequest(target.model);
|
|
189
|
+
if (request.kind === "concrete") return UNCHANGED;
|
|
190
|
+
const where = "`delegate_task` `target.model`";
|
|
191
|
+
if (request.kind === "invalid") return { kind: "block", reason: blockReason(where, quoted(request), request.problem) };
|
|
192
|
+
const provider = [target.providerInstanceId, target.driverKind].find((value) => value !== undefined && value !== "pi");
|
|
193
|
+
if (provider !== undefined) {
|
|
194
|
+
return {
|
|
195
|
+
kind: "block",
|
|
196
|
+
reason: blockReason(where, quoted(request), `the target names T3 provider ${safeEcho(provider)}, and a role reference names a Pi model. Use \`providerInstanceId: "pi"\`, or omit it.`),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const optionLevel = readThinkingOption(target.options);
|
|
200
|
+
if (optionLevel === "invalid") {
|
|
201
|
+
return {
|
|
202
|
+
kind: "block",
|
|
203
|
+
reason: blockReason(where, quoted(request), `\`target.options.thinking\` is not one of ${THINKING_LEVELS.map((level) => `\`${level}\``).join(", ")}. Lower the level with a \`:<level>\` suffix on the role reference instead.`),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const task = typeof args.task === "string" ? args.task : "";
|
|
207
|
+
const requested = lowest(request.thinking, optionLevel);
|
|
208
|
+
const route = await routeReference(request, where, requested, task, context);
|
|
209
|
+
if (route.kind === "block") return route;
|
|
210
|
+
const next = structuredClone(args);
|
|
211
|
+
const nextTarget = next.target as Record<string, unknown>;
|
|
212
|
+
if (route.kind === "inherit") {
|
|
213
|
+
// The provider selector goes too, because it would pick that instance's model instead of the
|
|
214
|
+
// parent's. Other fields stay for the filter gate to check. Omitted options inherit the parent's.
|
|
215
|
+
delete nextTarget.model;
|
|
216
|
+
delete nextTarget.providerInstanceId;
|
|
217
|
+
delete nextTarget.driverKind;
|
|
218
|
+
const lowered = requested !== undefined && context.parentThinking !== undefined ? lowest(requested, context.parentThinking) : undefined;
|
|
219
|
+
const options = writeThinkingOption(nextTarget.options, lowered);
|
|
220
|
+
if (options === undefined) delete nextTarget.options;
|
|
221
|
+
else nextTarget.options = options;
|
|
222
|
+
if (Object.keys(nextTarget).length === 0) delete next.target;
|
|
223
|
+
return { kind: "rewrite", input: next };
|
|
224
|
+
}
|
|
225
|
+
nextTarget.model = route.spec.model;
|
|
226
|
+
if (nextTarget.providerInstanceId === undefined) nextTarget.providerInstanceId = "pi";
|
|
227
|
+
// T3 merges given options over the inherited ones and reads empty options as clearing them, so a
|
|
228
|
+
// backend-default entry sends them empty rather than let the parent's thinking through.
|
|
229
|
+
nextTarget.options =
|
|
230
|
+
route.spec.thinking === undefined ? (Array.isArray(nextTarget.options) ? [] : {}) : writeThinkingOption(nextTarget.options, route.spec.thinking);
|
|
231
|
+
return { kind: "rewrite", input: next };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
type OptionEntry = { readonly id?: unknown; readonly value?: unknown };
|
|
235
|
+
|
|
236
|
+
/** T3 options are an object keyed by option id, or a list of `{ id, value }`. */
|
|
237
|
+
function readThinkingOption(options: unknown): ThinkingLevel | undefined | "invalid" {
|
|
238
|
+
const value = Array.isArray(options)
|
|
239
|
+
? (options as OptionEntry[]).find((entry) => isRecord(entry) && entry.id === "thinking")?.value
|
|
240
|
+
: isRecord(options)
|
|
241
|
+
? options.thinking
|
|
242
|
+
: undefined;
|
|
243
|
+
if (value === undefined) return undefined;
|
|
244
|
+
return typeof value === "string" && isThinkingLevel(value) ? value : "invalid";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function writeThinkingOption(options: unknown, thinking: ThinkingLevel | undefined): unknown {
|
|
248
|
+
if (Array.isArray(options)) {
|
|
249
|
+
const rest = (options as OptionEntry[]).filter((entry) => !(isRecord(entry) && entry.id === "thinking"));
|
|
250
|
+
const next = thinking === undefined ? rest : [...rest, { id: "thinking", value: thinking }];
|
|
251
|
+
return next.length > 0 ? next : undefined;
|
|
252
|
+
}
|
|
253
|
+
const next: Record<string, unknown> = isRecord(options) ? { ...options } : {};
|
|
254
|
+
if (thinking === undefined) delete next.thinking;
|
|
255
|
+
else next.thinking = thinking;
|
|
256
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface ModelSlot {
|
|
260
|
+
readonly where: string;
|
|
261
|
+
readonly holder: Record<string, unknown>;
|
|
262
|
+
readonly task: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Every place a `subagent` call names a child's model: one agent, a task list, or a chain. */
|
|
266
|
+
function subagentSlots(args: Record<string, unknown>): ModelSlot[] {
|
|
267
|
+
const topTask = typeof args.task === "string" ? args.task : "";
|
|
268
|
+
const taskOf = (holder: Record<string, unknown>, fallback: string) => (typeof holder.task === "string" ? holder.task : fallback);
|
|
269
|
+
const slots: ModelSlot[] = [];
|
|
270
|
+
if (args.agent !== undefined) {
|
|
271
|
+
if (args.model !== undefined && args.model !== null) slots.push({ where: "`subagent` `model`", holder: args, task: topTask });
|
|
272
|
+
else if (isRecord(args.options) && args.options.model !== undefined) slots.push({ where: "`subagent` `options.model`", holder: args.options, task: topTask });
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(args.tasks)) {
|
|
275
|
+
args.tasks.forEach((task, index) => {
|
|
276
|
+
if (isRecord(task)) slots.push({ where: `\`subagent\` tasks[${index}]`, holder: task, task: taskOf(task, topTask) });
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(args.chain)) {
|
|
280
|
+
args.chain.forEach((step, index) => {
|
|
281
|
+
if (!isRecord(step)) return;
|
|
282
|
+
const stepTask = taskOf(step, topTask);
|
|
283
|
+
slots.push({ where: `\`subagent\` chain[${index}]`, holder: step, task: stepTask });
|
|
284
|
+
if (Array.isArray(step.parallel)) {
|
|
285
|
+
step.parallel.forEach((task, inner) => {
|
|
286
|
+
if (isRecord(task)) slots.push({ where: `\`subagent\` chain[${index}].parallel[${inner}]`, holder: task, task: taskOf(task, stepTask) });
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return slots;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** pi-subagents takes `provider/model:level` in each child's `model`. Other actions launch nothing named here. */
|
|
295
|
+
async function routeSubagent(args: Record<string, unknown>, context: RouteContext): Promise<RouteDecision> {
|
|
296
|
+
if (typeof args.action === "string" && args.action.trim() !== "") return UNCHANGED;
|
|
297
|
+
const next = structuredClone(args);
|
|
298
|
+
let changed = false;
|
|
299
|
+
for (const slot of subagentSlots(next)) {
|
|
300
|
+
const request = parseModelRequest(slot.holder.model);
|
|
301
|
+
if (request.kind === "concrete") continue;
|
|
302
|
+
if (request.kind === "invalid") return { kind: "block", reason: blockReason(slot.where, quoted(request), request.problem) };
|
|
303
|
+
const route = await routeReference(request, slot.where, request.thinking, slot.task, context);
|
|
304
|
+
if (route.kind === "block") return route;
|
|
305
|
+
if (route.kind === "model") slot.holder.model = joinModelSpec(route.spec);
|
|
306
|
+
else if (context.parentModel !== undefined) {
|
|
307
|
+
const thinking = context.parentThinking === undefined ? undefined : lowest(request.thinking, context.parentThinking);
|
|
308
|
+
slot.holder.model = joinModelSpec({ model: context.parentModel, ...(thinking !== undefined ? { thinking } : {}) });
|
|
309
|
+
}
|
|
310
|
+
else delete slot.holder.model;
|
|
311
|
+
changed = true;
|
|
312
|
+
}
|
|
313
|
+
return changed ? { kind: "rewrite", input: next } : UNCHANGED;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
317
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
318
|
+
}
|
package/src/status.ts
CHANGED
|
@@ -176,7 +176,7 @@ function describeSeat(seat: PanelSeat): string {
|
|
|
176
176
|
case "model":
|
|
177
177
|
return `${describeSpec(seat.spec)}${origin}`;
|
|
178
178
|
case "choice":
|
|
179
|
-
return `
|
|
179
|
+
return `ranked ${seat.choices.map(describeSpec).join(", then ")}; as \`pstack:${seat.from}\` it runs once on the first, unless the delegating agent overrides the ranking${origin}`;
|
|
180
180
|
case "blocked":
|
|
181
181
|
return `BLOCKED, ${seat.reason}${origin}`;
|
|
182
182
|
}
|
|
@@ -184,14 +184,19 @@ function describeSeat(seat: PanelSeat): string {
|
|
|
184
184
|
|
|
185
185
|
const PARENT_MODEL = "the model of the agent that starts the panel";
|
|
186
186
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The model a seat runs on, when it is already determined. A default seat runs as `pstack:<role>`,
|
|
189
|
+
* so with `ranked` its first choice counts; without it, a seat with several choices is open,
|
|
190
|
+
* because the delegating agent may override the ranking.
|
|
191
|
+
*/
|
|
192
|
+
function seatModel(seat: PanelSeat, ranked: boolean): string | undefined {
|
|
189
193
|
switch (seat.kind) {
|
|
190
194
|
case "parent":
|
|
191
195
|
return PARENT_MODEL;
|
|
192
196
|
case "model":
|
|
193
197
|
return seat.spec.model;
|
|
194
198
|
case "choice": {
|
|
199
|
+
if (ranked) return seat.choices[0]?.model;
|
|
195
200
|
const models = new Set(seat.choices.map((choice) => choice.model));
|
|
196
201
|
return models.size === 1 ? [...models][0] : undefined;
|
|
197
202
|
}
|
|
@@ -200,10 +205,10 @@ function seatModel(seat: PanelSeat): string | undefined {
|
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
207
|
|
|
203
|
-
function seatModels(seats: readonly PanelSeat[]): Set<string> | undefined {
|
|
208
|
+
function seatModels(seats: readonly PanelSeat[], ranked: boolean): Set<string> | undefined {
|
|
204
209
|
const models = new Set<string>();
|
|
205
210
|
for (const seat of seats) {
|
|
206
|
-
const model = seatModel(seat);
|
|
211
|
+
const model = seatModel(seat, ranked);
|
|
207
212
|
if (model !== undefined) models.add(model);
|
|
208
213
|
else if (seat.kind === "choice") return undefined;
|
|
209
214
|
}
|
|
@@ -211,13 +216,13 @@ function seatModels(seats: readonly PanelSeat[]): Set<string> | undefined {
|
|
|
211
216
|
}
|
|
212
217
|
|
|
213
218
|
/**
|
|
214
|
-
* Upstream's default panels span
|
|
219
|
+
* Upstream's default panels span several model families and two vendors. A panel whose seats all land on one model
|
|
215
220
|
* lost that because of configuration, not because no other model exists, so say so where the
|
|
216
221
|
* agent reads it. Families are left to the agent, which knows them better than a prefix list.
|
|
217
222
|
*/
|
|
218
223
|
export function panelWarning(panel: ResolvedPanel, config: AdapterConfig, context: ResolutionContext = {}): string | undefined {
|
|
219
224
|
const usable = panel.seats.filter((seat) => seat.kind !== "blocked");
|
|
220
|
-
const models = seatModels(usable);
|
|
225
|
+
const models = seatModels(usable, true);
|
|
221
226
|
const pool = panel.role === "arena-cross-judge-pool";
|
|
222
227
|
// Upstream lets a panel line hold one entry on purpose, so only the judge pool warns at one seat.
|
|
223
228
|
if (models === undefined || models.size !== 1 || (usable.length < 2 && !pool)) return undefined;
|
|
@@ -226,21 +231,23 @@ export function panelWarning(panel: ResolvedPanel, config: AdapterConfig, contex
|
|
|
226
231
|
const cause = panel.configured
|
|
227
232
|
? `its configured entries all name ${named}`
|
|
228
233
|
: `it is unset, and ${PANEL_DEFAULT_ROLES.map((role) => `\`${role}\``).join(" and ")} both resolve to ${named}`;
|
|
229
|
-
|
|
234
|
+
const ranked = usable.some((seat) => seat.kind === "choice") ? " with the ranked picks, unless the delegating agent overrides the ranking," : ",";
|
|
235
|
+
return `Every seat of \`${panel.role}\` runs on ${named}${ranked} because ${cause}, ${pool ? poolEffect(panel, config, model, context) : "so the panel has one model and no second family"}. This comes from the adapter configuration, not from which models are available. Run \`/skill:${config.namespace}-setup\` to put this panel on models from two vendors, as upstream's defaults do.`;
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
/**
|
|
233
|
-
* Arena's judge is never from the session's
|
|
239
|
+
* Arena's judge is never from the session's vendor, which since pstack 0.15.42 counts Fable and the
|
|
240
|
+
* other Claude models as one. When no pool entry qualifies, upstream tries
|
|
234
241
|
* its default pool, which here is `judgment`, then `general`, before it skips the judge.
|
|
235
242
|
*/
|
|
236
243
|
function poolEffect(panel: ResolvedPanel, config: AdapterConfig, model: string | undefined, context: ResolutionContext): string {
|
|
237
244
|
const fallback = defaultSeats(config, context);
|
|
238
|
-
const fallbackModels = seatModels(fallback);
|
|
245
|
+
const fallbackModels = seatModels(fallback, false);
|
|
239
246
|
const lead =
|
|
240
247
|
model === PARENT_MODEL
|
|
241
248
|
? "and an `inherit` entry never judges, so arena"
|
|
242
|
-
: `so a session on the
|
|
243
|
-
const sameFallback =
|
|
249
|
+
: `so a session on the vendor of \`${model}\` gets no judge from the pool, and arena`;
|
|
250
|
+
const sameFallback = fallbackModels?.size === 1 && fallbackModels.has(model ?? PARENT_MODEL);
|
|
244
251
|
return sameFallback
|
|
245
252
|
? `${lead} skips the judge`
|
|
246
253
|
: `${lead} tries its default pool, ${fallback.map(describeSeat).join("; ")}, before it skips the judge`;
|
|
@@ -273,7 +280,7 @@ function describeRole(resolution: RoleResolution, role: string): readonly [strin
|
|
|
273
280
|
const eligible = resolution.choices.filter((choice) => choice.eligible).map((choice) => describeSpec(choice.spec));
|
|
274
281
|
const excluded = resolution.choices.filter((choice) => !choice.eligible).map((choice) => describeSpec(choice.spec));
|
|
275
282
|
const tail = excluded.length > 0 ? `; excluded by the model filter: ${excluded.join(", ")}` : "";
|
|
276
|
-
return [`pool,
|
|
283
|
+
return [`pool, ranked ${eligible.join(", then ")}${tail}${via}`, `per choice${via}`];
|
|
277
284
|
}
|
|
278
285
|
}
|
|
279
286
|
}
|
|
@@ -297,7 +304,7 @@ export function renderModelPolicy(config: AdapterConfig, origin: FilterOrigin, p
|
|
|
297
304
|
}
|
|
298
305
|
if (pools.length > 0) {
|
|
299
306
|
lines.push(
|
|
300
|
-
`Pools: ${pools.map(([role, resolution]) => `\`${role}\`
|
|
307
|
+
`Pools: ${pools.map(([role, resolution]) => `\`${role}\` is ranked ${describeRole(resolution, role)[0].replace(/^pool, ranked /, "")}`).join("; ")}. Delegate a role as \`pstack:<role>\`, and the adapter runs it on the first eligible model in the role's ranking. As the delegating agent, you may override the ranking with \`pstack:<role>@<provider/model>\`, naming another eligible model from the pool when the task clearly fits it better; say which model you picked and why. A pool is not a retry list and not a panel: do not start the same task on several of its models, and when a delegation fails, report the failure instead of trying another model.`,
|
|
301
308
|
);
|
|
302
309
|
}
|
|
303
310
|
if (blocked.length > 0) {
|
package/src/vocabulary.ts
CHANGED
|
@@ -33,8 +33,9 @@ export const CURSOR_TERMS: readonly CursorTerm[] = [
|
|
|
33
33
|
{ key: "`resume`", pattern: /`resume`/ },
|
|
34
34
|
{ key: "`file_attachments`", pattern: /file_attachments/ },
|
|
35
35
|
{ key: "`claude-opus-5-5-xhigh`", pattern: /\bclaude-[a-z]+-\d/ },
|
|
36
|
-
{ key: "`grok
|
|
36
|
+
{ key: "`grok-*`", pattern: /\bgrok-\d/ },
|
|
37
37
|
{ key: "`gpt-*`", pattern: /\bgpt-\d/ },
|
|
38
|
+
{ key: "`gemini-4-carbon-high`", pattern: /\bgemini-\d/ },
|
|
38
39
|
{ key: "`inherit-parent`", pattern: /inherit-parent/ },
|
|
39
40
|
{ key: "`~/.cursor/rules/pstack-models.mdc`", pattern: /pstack-models\.mdc/ },
|
|
40
41
|
{ key: "`.cursor/skills/`", pattern: /\.cursor\/skills/ },
|
|
@@ -53,6 +54,7 @@ export const CURSOR_TERMS: readonly CursorTerm[] = [
|
|
|
53
54
|
{ key: "Cursor `/loop`", pattern: /(^|[\s(`])\/loop\b/ },
|
|
54
55
|
{ key: "a skill under `plugins/cache/`", pattern: /plugins\/cache\// },
|
|
55
56
|
{ key: "a Custom Mode", pattern: /Custom Mode/ },
|
|
57
|
+
{ key: "`# defaults: 2`", pattern: /# defaults: \d/ },
|
|
56
58
|
];
|
|
57
59
|
|
|
58
60
|
/** One upstream Markdown file, named by its skill and its path inside the skill directory. */
|