pi-pstack-adapter 0.7.0 → 0.9.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/src/status.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { CERTIFIED } from "./certified.ts";
2
- import { CAPABILITY_LABELS, GENERAL_ROLE, MODEL_ROLES, TIER_ORDER } from "./registry.ts";
3
- import type { AdapterConfig } from "./config.ts";
4
- import { splitModelSpec, trustWarning } from "./config.ts";
2
+ import { CAPABILITY_LABELS, MODEL_ROLES, PANEL_DEFAULT_ROLES, PANEL_ROLES, TIER_ORDER } from "./registry.ts";
3
+ import type { AdapterConfig, ModelSpec } from "./config.ts";
4
+ import { trustWarning } from "./config.ts";
5
+ import { type FilterOrigin, briefLine, describeOrigin, filterOf, isRestricting } from "./filter.ts";
6
+ import { type PanelSeat, type ResolutionContext, type ResolvedPanel, type RoleResolution, defaultSeats, resolvePanel, resolveRole } from "./pools.ts";
7
+
8
+ export { resolvePanel } from "./pools.ts";
9
+ export type { PanelSeat, ResolvedPanel } from "./pools.ts";
5
10
  import { DELEGATION_BACKENDS, delegationOrder } from "./delegation.ts";
6
11
  import type { PrerequisiteReport } from "./capabilities.ts";
7
12
  import type {
@@ -30,10 +35,16 @@ export interface StatusInput {
30
35
  readonly bootstrapped: boolean;
31
36
  /** Runtime findings that generation could not know, such as agent registration results. */
32
37
  readonly runtimeDiagnostics?: readonly Diagnostic[];
38
+ /** The model filter in force and where it came from. None when omitted. */
39
+ readonly filter?: FilterOrigin;
40
+ /** `provider/id` of the session's model, which an inheriting role runs on. */
41
+ readonly parentModel?: string;
33
42
  }
34
43
 
35
44
  export function renderStatus(input: StatusInput): string {
36
45
  const { config, source } = input;
46
+ const origin = input.filter ?? { kind: "none" };
47
+ const context: ResolutionContext = { filter: filterOf(origin), parentModel: input.parentModel };
37
48
  const lines: string[] = [];
38
49
 
39
50
  lines.push(`# ${config.namespace} adapter status`);
@@ -60,6 +71,7 @@ export function renderStatus(input: StatusInput): string {
60
71
  lines.push(`| Poteto Mode | ${input.modeActive ? "active" : "off"} |`);
61
72
  lines.push(`| delegation order | ${delegationOrder(config.delegation).map((backend) => backend.id).join(", then ")} |`);
62
73
  lines.push(`| delegation | ${describeDelegation(input.capabilities)} |`);
74
+ lines.push(`| model filter | ${describeOrigin(origin)} |`);
63
75
  lines.push("");
64
76
 
65
77
  const warning = trustWarning(source.trust, config);
@@ -90,23 +102,35 @@ export function renderStatus(input: StatusInput): string {
90
102
  lines.push("");
91
103
  lines.push(`| role | model | thinking |`);
92
104
  lines.push(`| --- | --- | --- |`);
93
- const extra = Object.keys(config.models).filter((role) => !MODEL_ROLES.includes(role)).sort();
94
- const general = config.models[GENERAL_ROLE];
95
- for (const role of [...MODEL_ROLES, ...extra]) {
96
- const configured = config.models[role];
97
- if (configured === undefined && general !== undefined) {
98
- const spec = splitModelSpec(general);
99
- lines.push(`| ${role} | ${spec.model} (from ${GENERAL_ROLE}) | ${spec.thinking ?? "backend default"} (from ${GENERAL_ROLE}) |`);
100
- continue;
101
- }
102
- if (configured === undefined) {
103
- lines.push(`| ${role} | inherit (parent session model) | inherit (parent session thinking) |`);
104
- continue;
105
- }
106
- const spec = splitModelSpec(configured);
107
- lines.push(`| ${role} | ${spec.model} | ${spec.thinking ?? "backend default"} |`);
105
+ for (const role of [...MODEL_ROLES, ...extraRoles(config)]) {
106
+ const [model, thinking] = describeRole(resolveRole(config, role, context), role);
107
+ lines.push(`| ${role} | ${model} | ${thinking} |`);
108
+ }
109
+ lines.push("");
110
+ const policy = renderModelPolicy(config, origin, input.parentModel);
111
+ if (policy) {
112
+ lines.push(policy);
113
+ lines.push("");
114
+ }
115
+
116
+ lines.push(`## Panel roles`);
117
+ lines.push("");
118
+ lines.push("A panel runs one seat per entry. An unset panel uses `judgment`, then `general`.");
119
+ lines.push("");
120
+ lines.push(`| panel | seats | source |`);
121
+ lines.push(`| --- | --- | --- |`);
122
+ const panels = PANEL_ROLES.map((panel) => resolvePanel(config, panel, context));
123
+ for (const panel of panels) {
124
+ lines.push(`| ${panel.role} | ${panel.seats.map(describeSeat).join("; ")} | ${panel.configured ? "configured" : `unset, so ${PANEL_DEFAULT_ROLES.join(", ")}`} |`);
108
125
  }
109
126
  lines.push("");
127
+ for (const panel of panels) {
128
+ const warning = panelWarning(panel, config, context);
129
+ if (warning) {
130
+ lines.push(`> Warning. ${warning}`);
131
+ lines.push("");
132
+ }
133
+ }
110
134
 
111
135
  lines.push(`## Support tiers`);
112
136
  lines.push("");
@@ -140,6 +164,156 @@ export function renderStatus(input: StatusInput): string {
140
164
  return lines.join("\n");
141
165
  }
142
166
 
167
+ function describeSpec(spec: ModelSpec): string {
168
+ return `${spec.model} at ${spec.thinking ?? "backend default"}`;
169
+ }
170
+
171
+ function describeSeat(seat: PanelSeat): string {
172
+ const origin = seat.from ? ` (${seat.from})` : "";
173
+ switch (seat.kind) {
174
+ case "parent":
175
+ return `model of the agent that starts the panel${origin}`;
176
+ case "model":
177
+ return `${describeSpec(seat.spec)}${origin}`;
178
+ case "choice":
179
+ return `one of ${seat.choices.map(describeSpec).join(" or ")}, picked once for this seat${origin}`;
180
+ case "blocked":
181
+ return `BLOCKED, ${seat.reason}${origin}`;
182
+ }
183
+ }
184
+
185
+ const PARENT_MODEL = "the model of the agent that starts the panel";
186
+
187
+ /** The model a seat runs on, when it is already determined. */
188
+ function seatModel(seat: PanelSeat): string | undefined {
189
+ switch (seat.kind) {
190
+ case "parent":
191
+ return PARENT_MODEL;
192
+ case "model":
193
+ return seat.spec.model;
194
+ case "choice": {
195
+ const models = new Set(seat.choices.map((choice) => choice.model));
196
+ return models.size === 1 ? [...models][0] : undefined;
197
+ }
198
+ case "blocked":
199
+ return undefined;
200
+ }
201
+ }
202
+
203
+ function seatModels(seats: readonly PanelSeat[]): Set<string> | undefined {
204
+ const models = new Set<string>();
205
+ for (const seat of seats) {
206
+ const model = seatModel(seat);
207
+ if (model !== undefined) models.add(model);
208
+ else if (seat.kind === "choice") return undefined;
209
+ }
210
+ return models;
211
+ }
212
+
213
+ /**
214
+ * Upstream's default panels span two model families. A panel whose seats all land on one model
215
+ * lost that because of configuration, not because no other model exists, so say so where the
216
+ * agent reads it. Families are left to the agent, which knows them better than a prefix list.
217
+ */
218
+ export function panelWarning(panel: ResolvedPanel, config: AdapterConfig, context: ResolutionContext = {}): string | undefined {
219
+ const usable = panel.seats.filter((seat) => seat.kind !== "blocked");
220
+ const models = seatModels(usable);
221
+ const pool = panel.role === "arena-cross-judge-pool";
222
+ // Upstream lets a panel line hold one entry on purpose, so only the judge pool warns at one seat.
223
+ if (models === undefined || models.size !== 1 || (usable.length < 2 && !pool)) return undefined;
224
+ const [model] = [...models];
225
+ const named = model === PARENT_MODEL ? PARENT_MODEL : `\`${model}\``;
226
+ const cause = panel.configured
227
+ ? `its configured entries all name ${named}`
228
+ : `it is unset, and ${PANEL_DEFAULT_ROLES.map((role) => `\`${role}\``).join(" and ")} both resolve to ${named}`;
229
+ return `Every seat of \`${panel.role}\` runs on ${named}, 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 two model families, as upstream's defaults do.`;
230
+ }
231
+
232
+ /**
233
+ * Arena's judge is never from the session's family. When no pool entry qualifies, upstream tries
234
+ * its default pool, which here is `judgment`, then `general`, before it skips the judge.
235
+ */
236
+ function poolEffect(panel: ResolvedPanel, config: AdapterConfig, model: string | undefined, context: ResolutionContext): string {
237
+ const fallback = defaultSeats(config, context);
238
+ const fallbackModels = seatModels(fallback);
239
+ const lead =
240
+ model === PARENT_MODEL
241
+ ? "and an `inherit` entry never judges, so arena"
242
+ : `so a session on the family of \`${model}\` gets no judge from the pool, and arena`;
243
+ const sameFallback = !panel.configured || (fallbackModels?.size === 1 && fallbackModels.has(model ?? PARENT_MODEL));
244
+ return sameFallback
245
+ ? `${lead} skips the judge`
246
+ : `${lead} tries its default pool, ${fallback.map(describeSeat).join("; ")}, before it skips the judge`;
247
+ }
248
+
249
+ /** The panels a workflow reads, for its `check` report. */
250
+ export function renderPanels(config: AdapterConfig, roles: readonly string[], context: ResolutionContext = {}): string {
251
+ const lines = roles.map((role) => {
252
+ const panel = resolvePanel(config, role, context);
253
+ const source = panel.configured ? "configured" : `unset, so ${PANEL_DEFAULT_ROLES.join(", ")}`;
254
+ const warning = panelWarning(panel, config, context);
255
+ return `- \`${role}\` (${source}): ${panel.seats.map(describeSeat).join("; ")}.${warning ? ` Warning. ${warning}` : ""}`;
256
+ });
257
+ return `Panels this workflow uses, one seat per entry:\n${lines.join("\n")}`;
258
+ }
259
+
260
+ /** One `model roles` row: the model column and the thinking column. */
261
+ function describeRole(resolution: RoleResolution, role: string): readonly [string, string] {
262
+ const via = resolution.from === role ? "" : ` (from ${resolution.from})`;
263
+ switch (resolution.kind) {
264
+ case "inherit":
265
+ return ["inherit (parent session model)", "inherit (parent session thinking)"];
266
+ case "blocked": {
267
+ const excluded = resolution.choices.map((choice) => choice.spec.model);
268
+ return [`BLOCKED, ${resolution.reason}${excluded.length > 0 ? `: ${excluded.join(", ")}` : ""}. Do not delegate this role${via}`, "-"];
269
+ }
270
+ case "pool": {
271
+ const [only] = resolution.choices;
272
+ if (resolution.choices.length === 1 && only) return [`${only.spec.model}${via}`, `${only.spec.thinking ?? "backend default"}${via}`];
273
+ const eligible = resolution.choices.filter((choice) => choice.eligible).map((choice) => describeSpec(choice.spec));
274
+ const excluded = resolution.choices.filter((choice) => !choice.eligible).map((choice) => describeSpec(choice.spec));
275
+ const tail = excluded.length > 0 ? `; excluded by the model filter: ${excluded.join(", ")}` : "";
276
+ return [`pool, pick one of ${eligible.join(", ")}${tail}${via}`, `per choice${via}`];
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * The delegation rules a workflow's `check` report adds when pools, a model filter, or blocked roles
283
+ * are in play. Nothing when none of them is, so the report stays as it was.
284
+ */
285
+ export function renderModelPolicy(config: AdapterConfig, origin: FilterOrigin, parentModel: string | undefined): string | undefined {
286
+ const filter = filterOf(origin);
287
+ const context: ResolutionContext = { filter, parentModel };
288
+ const roles = [...MODEL_ROLES, ...extraRoles(config)];
289
+ const resolutions = roles.map((role) => [role, resolveRole(config, role, context)] as const);
290
+ const blocked = resolutions.filter(([, resolution]) => resolution.kind === "blocked");
291
+ const pools = resolutions.filter(([, resolution]) => resolution.kind === "pool" && resolution.choices.length > 1);
292
+ if (!isRestricting(filter) && pools.length === 0 && blocked.length === 0) return undefined;
293
+ const lines: string[] = [];
294
+ if (isRestricting(filter)) {
295
+ lines.push(`Model filter: ${describeOrigin(origin)}. The adapter blocks a delegation whose model it excludes, or whose model it cannot tell.`);
296
+ lines.push(`Put this line in each brief, verbatim: "${briefLine(filter)}"`);
297
+ }
298
+ if (pools.length > 0) {
299
+ lines.push(
300
+ `Pools: ${pools.map(([role, resolution]) => `\`${role}\` picks one of ${describeRole(resolution, role)[0].replace(/^pool, pick one of /, "")}`).join("; ")}. For each delegation, pick ONE eligible model from the role's pool by fit. 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
+ );
302
+ }
303
+ if (blocked.length > 0) {
304
+ lines.push(
305
+ `Blocked roles: ${blocked.map(([role, resolution]) => `\`${role}\` (${resolution.kind === "blocked" ? resolution.reason : ""})`).join("; ")}. Do not delegate work for a blocked role, and do not move it to \`general\` or your own model. Tell the user which role is blocked and why.`,
306
+ );
307
+ }
308
+ return lines.join("\n\n");
309
+ }
310
+
311
+ function extraRoles(config: AdapterConfig): string[] {
312
+ return Object.keys(config.models)
313
+ .filter((role) => !MODEL_ROLES.includes(role) && !PANEL_ROLES.includes(role))
314
+ .sort();
315
+ }
316
+
143
317
  function describeDelegation(capabilities: readonly CapabilityStatus[]): string {
144
318
  const backend = capabilities.find((capability) => capability.id === "delegation")?.backend;
145
319
  if (!backend) return "none available";
@@ -178,6 +352,10 @@ export function renderCheck(
178
352
  report: PrerequisiteReport,
179
353
  /** The resolved `delegation` capability, named for a workflow that delegates. */
180
354
  delegation?: CapabilityStatus,
355
+ /** The workflow's panels, from `renderPanels`. */
356
+ panels?: string,
357
+ /** Pools, the model filter, and blocked roles, from `renderModelPolicy`. */
358
+ policy?: string,
181
359
  ): string {
182
360
  const parts = [`\`/skill:${skill.generatedName}\` is tier **${skill.tier}**.`];
183
361
  if (entry.note) parts.push(entry.note);
@@ -196,6 +374,8 @@ export function renderCheck(
196
374
  delegation?.backend ? delegationInstruction(delegation.backend, delegation.tools) : "No delegation tool is usable in this session, so do not delegate.",
197
375
  );
198
376
  }
377
+ if (panels && skill.tier !== "unsupported") parts.push(panels);
378
+ if (policy && skill.capabilities.includes("delegation") && skill.tier !== "unsupported") parts.push(policy);
199
379
  if (skill.tier === "experimental" && report.ok) {
200
380
  parts.push("Tier `experimental` means parts of this workflow have no Pi equivalent. Expect gaps.");
201
381
  }
@@ -0,0 +1,123 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import type { Diagnostic } from "./types.ts";
4
+
5
+ /**
6
+ * A Cursor name that an upstream body can use. `key` must appear in the Cursor column of a
7
+ * host mapping row, so deleting the row, or adding a term without one, fails certification.
8
+ */
9
+ export interface CursorTerm {
10
+ readonly key: string;
11
+ readonly pattern: RegExp;
12
+ }
13
+
14
+ /**
15
+ * Ported from the `TERMS` list in upstream's `scripts/generate-pstack-harness-trees.mjs`, which
16
+ * fails when a skill uses a term that `pstack/docs/harness/claude-code.md` has no row for. The
17
+ * keys name this adapter's rows. Upstream's single "Cursor-only features" term is split into one
18
+ * term per feature, and the terms after it cover rows upstream does not scan for.
19
+ */
20
+ export const CURSOR_TERMS: readonly CursorTerm[] = [
21
+ { key: "`Task` tool", pattern: /`Task`|\bTask (tool|subagent|call)/ },
22
+ { key: "`AskQuestion`", pattern: /AskQuestion/ },
23
+ { key: "todolist", pattern: /\btodo ?list\b/i },
24
+ { key: "`subagent_type: generalPurpose`", pattern: /generalPurpose/ },
25
+ { key: "`poteto-agent`", pattern: /poteto-agent/ },
26
+ { key: "`comment-sicko`", pattern: /comment-sicko/ },
27
+ { key: "`readonly: true`", pattern: /readonly: true|readonly\/Ask/ },
28
+ { key: "`readonly: false`", pattern: /readonly: false/ },
29
+ { key: "`environment: \"cloud\"`", pattern: /environment: "cloud"/ },
30
+ { key: "`environment: \"local\"`", pattern: /environment: "local"/ },
31
+ { key: "`cloud_base_branch`", pattern: /cloud_base_branch/ },
32
+ { key: "`run_in_background: true`", pattern: /run_in_background/ },
33
+ { key: "`resume`", pattern: /`resume`/ },
34
+ { key: "`file_attachments`", pattern: /file_attachments/ },
35
+ { key: "`claude-opus-5-5-xhigh`", pattern: /\bclaude-[a-z]+-\d/ },
36
+ { key: "`grok-4.7-xhigh-fast`", pattern: /\bgrok-\d/ },
37
+ { key: "`gpt-*`", pattern: /\bgpt-\d/ },
38
+ { key: "`inherit-parent`", pattern: /inherit-parent/ },
39
+ { key: "`~/.cursor/rules/pstack-models.mdc`", pattern: /pstack-models\.mdc/ },
40
+ { key: "`.cursor/skills/`", pattern: /\.cursor\/skills/ },
41
+ { key: "`~/.cursor/plugins/`", pattern: /\.cursor\/plugins|\.cursor-plugin/ },
42
+ // pstack-models.mdc has its own row, so it does not count as a use of the general rule row.
43
+ { key: "`.cursor/rules/*.mdc`", pattern: /\.cursor\/rules(?!\/pstack-models\.mdc)|always-applied rule/ },
44
+ { key: "agent-transcripts", pattern: /agent-transcripts|\.cursor\/projects/ },
45
+ { key: "Bugbot", pattern: /Bugbot/ },
46
+ { key: "Cursor Automations", pattern: /Cursor [Aa]utomation|cursor\.(sh|com)\/automations/ },
47
+ { key: "Cursor cloud agents", pattern: /[Cc]loud [Aa]gent/ },
48
+ { key: "a built-in PR tool", pattern: /built-in PR tool/i },
49
+ { key: "`Grep`, `Glob`", pattern: /\bGrep\b|\bGlob\b/ },
50
+ { key: "`AskUserQuestion`", pattern: /AskUserQuestion/ },
51
+ { key: "`TodoWrite`", pattern: /TodoWrite|TaskCreate|TaskUpdate/ },
52
+ { key: "`/visualize`", pattern: /\/visualize\b/ },
53
+ { key: "Cursor `/loop`", pattern: /(^|[\s(`])\/loop\b/ },
54
+ { key: "a skill under `plugins/cache/`", pattern: /plugins\/cache\// },
55
+ { key: "a Custom Mode", pattern: /Custom Mode/ },
56
+ ];
57
+
58
+ /** One upstream Markdown file, named by its skill and its path inside the skill directory. */
59
+ export interface SkillMarkdown {
60
+ readonly skill: string;
61
+ readonly path: string;
62
+ readonly text: string;
63
+ }
64
+
65
+ /**
66
+ * The entry file generation reads, then every other Markdown file under the skill directory in
67
+ * sorted path order. The entry is read through `entryPath` even when it is a symbolic link,
68
+ * because generation follows it too. Other symbolic links below `dir` are not followed, as
69
+ * generation skips them when it copies support files. `dir` itself comes from discovery, which
70
+ * lists only real directories, not symbolic links, under the manifest's skills directory.
71
+ */
72
+ export function skillMarkdown(skill: string, dir: string, entryPath: string): SkillMarkdown[] {
73
+ const files: SkillMarkdown[] = [{ skill, path: "SKILL.md", text: readFileSync(entryPath, "utf8") }];
74
+ const visit = (current: string) => {
75
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
76
+ const path = join(current, entry.name);
77
+ if (current === dir && entry.name === "SKILL.md") continue;
78
+ if (entry.isSymbolicLink()) continue;
79
+ if (entry.isDirectory()) visit(path);
80
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
81
+ files.push({ skill, path: relative(dir, path).split(sep).join("/"), text: readFileSync(path, "utf8") });
82
+ }
83
+ }
84
+ };
85
+ visit(dir);
86
+ return files;
87
+ }
88
+
89
+ /** The Cursor column of every table row in a host mapping document. */
90
+ export function mappedCursorNames(mapping: string): string[] {
91
+ return mapping
92
+ .split(/\r?\n/)
93
+ .filter((line) => line.startsWith("| ") && !/^\| [-\s|]+$/.test(line))
94
+ .map((line) => {
95
+ const end = line.indexOf(" | ", 1);
96
+ return end < 0 ? line.slice(2) : line.slice(2, end);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * One warning per term that an upstream skill uses and no host mapping row names, in term order
102
+ * with skills sorted, so a revision with new Cursor vocabulary fails certification.
103
+ */
104
+ export function vocabularyDiagnostics(
105
+ files: readonly SkillMarkdown[],
106
+ mapping: string,
107
+ terms: readonly CursorTerm[] = CURSOR_TERMS,
108
+ ): Diagnostic[] {
109
+ const cursorColumn = mappedCursorNames(mapping);
110
+ const diagnostics: Diagnostic[] = [];
111
+ for (const term of terms) {
112
+ if (cursorColumn.some((cell) => cell.includes(term.key))) continue;
113
+ const skills = [...new Set(files.filter((file) => term.pattern.test(file.text)).map((file) => file.skill))].sort();
114
+ if (skills.length === 0) continue;
115
+ diagnostics.push({
116
+ level: "warning",
117
+ resource: `host-mapping:${term.key}`,
118
+ message: `Upstream ${skills.length === 1 ? "skill" : "skills"} ${skills.map((name) => `\`${name}\``).join(", ")} ${skills.length === 1 ? "uses" : "use"} the Cursor term ${term.key}, but no host mapping row names it in its Cursor column, so Pi would have to guess what it means.`,
119
+ action: `Read what the term means in those skills and in upstream's pstack/docs/harness/claude-code.md at this commit, then add a row that names ${term.key} to hostMappingDocument in src/generate.ts and rerun the probe.`,
120
+ });
121
+ }
122
+ return diagnostics;
123
+ }