pi-pstack-adapter 0.6.0 → 0.8.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,7 @@
1
1
  import { CERTIFIED } from "./certified.ts";
2
- import { CAPABILITY_LABELS, 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, GENERAL_ROLE, MODEL_ROLES, PANEL_DEFAULT_ROLES, PANEL_ROLES, TIER_ORDER } from "./registry.ts";
3
+ import type { AdapterConfig, ModelSpec } from "./config.ts";
4
+ import { panelEntries, splitModelSpec, trustWarning } from "./config.ts";
5
5
  import { DELEGATION_BACKENDS, delegationOrder } from "./delegation.ts";
6
6
  import type { PrerequisiteReport } from "./capabilities.ts";
7
7
  import type {
@@ -90,9 +90,17 @@ export function renderStatus(input: StatusInput): string {
90
90
  lines.push("");
91
91
  lines.push(`| role | model | thinking |`);
92
92
  lines.push(`| --- | --- | --- |`);
93
- const extra = Object.keys(config.models).filter((role) => !MODEL_ROLES.includes(role)).sort();
93
+ const extra = Object.keys(config.models)
94
+ .filter((role) => !MODEL_ROLES.includes(role) && !PANEL_ROLES.includes(role))
95
+ .sort();
96
+ const general = config.models[GENERAL_ROLE];
94
97
  for (const role of [...MODEL_ROLES, ...extra]) {
95
98
  const configured = config.models[role];
99
+ if (configured === undefined && general !== undefined) {
100
+ const spec = splitModelSpec(general);
101
+ lines.push(`| ${role} | ${spec.model} (from ${GENERAL_ROLE}) | ${spec.thinking ?? "backend default"} (from ${GENERAL_ROLE}) |`);
102
+ continue;
103
+ }
96
104
  if (configured === undefined) {
97
105
  lines.push(`| ${role} | inherit (parent session model) | inherit (parent session thinking) |`);
98
106
  continue;
@@ -102,6 +110,25 @@ export function renderStatus(input: StatusInput): string {
102
110
  }
103
111
  lines.push("");
104
112
 
113
+ lines.push(`## Panel roles`);
114
+ lines.push("");
115
+ lines.push("A panel runs one seat per entry. An unset panel uses `judgment`, then `general`.");
116
+ lines.push("");
117
+ lines.push(`| panel | seats | source |`);
118
+ lines.push(`| --- | --- | --- |`);
119
+ const panels = PANEL_ROLES.map((panel) => resolvePanel(config, panel));
120
+ for (const panel of panels) {
121
+ lines.push(`| ${panel.role} | ${panel.seats.map(describeSeat).join("; ")} | ${panel.configured ? "configured" : `unset, so ${PANEL_DEFAULT_ROLES.join(", ")}`} |`);
122
+ }
123
+ lines.push("");
124
+ for (const panel of panels) {
125
+ const warning = panelWarning(panel, config);
126
+ if (warning) {
127
+ lines.push(`> Warning. ${warning}`);
128
+ lines.push("");
129
+ }
130
+ }
131
+
105
132
  lines.push(`## Support tiers`);
106
133
  lines.push("");
107
134
  const byTier = groupByTier(input.generation);
@@ -134,6 +161,99 @@ export function renderStatus(input: StatusInput): string {
134
161
  return lines.join("\n");
135
162
  }
136
163
 
164
+ /** One seat of a panel. `spec` is undefined when the seat runs on the model of the agent that starts the panel. */
165
+ export interface PanelSeat {
166
+ readonly spec?: ModelSpec;
167
+ /** The role a default seat came from, such as `general` for an unset `judgment`. */
168
+ readonly from?: string;
169
+ }
170
+
171
+ export interface ResolvedPanel {
172
+ readonly role: string;
173
+ readonly configured: boolean;
174
+ readonly seats: readonly PanelSeat[];
175
+ }
176
+
177
+ /** A single role's model as delegation resolves it: the role, then `general`, then the parent. */
178
+ function resolveRoleSeat(config: AdapterConfig, role: string): PanelSeat {
179
+ const configured = config.models[role];
180
+ if (configured !== undefined) return { spec: splitModelSpec(configured), from: role };
181
+ const general = config.models[GENERAL_ROLE];
182
+ if (general !== undefined) return { spec: splitModelSpec(general), from: GENERAL_ROLE };
183
+ return { from: role };
184
+ }
185
+
186
+ export function resolvePanel(config: AdapterConfig, role: string): ResolvedPanel {
187
+ const configured = config.models[role];
188
+ if (configured !== undefined) {
189
+ return {
190
+ role,
191
+ configured: true,
192
+ seats: panelEntries(configured).map((entry) => (entry.kind === "inherit" ? {} : { spec: entry.spec })),
193
+ };
194
+ }
195
+ return { role, configured: false, seats: defaultSeats(config) };
196
+ }
197
+
198
+ /** The seats an unset panel gets, which are also the default pool arena falls back to. */
199
+ function defaultSeats(config: AdapterConfig): PanelSeat[] {
200
+ return PANEL_DEFAULT_ROLES.map((name) => resolveRoleSeat(config, name));
201
+ }
202
+
203
+ function describeSeat(seat: PanelSeat): string {
204
+ const origin = seat.from ? ` (${seat.from})` : "";
205
+ if (!seat.spec) return `model of the agent that starts the panel${origin}`;
206
+ return `${seat.spec.model} at ${seat.spec.thinking ?? "backend default"}${origin}`;
207
+ }
208
+
209
+ const PARENT_MODEL = "the model of the agent that starts the panel";
210
+
211
+ /**
212
+ * Upstream's default panels span two model families. A panel whose seats all land on one model
213
+ * lost that because of configuration, not because no other model exists, so say so where the
214
+ * agent reads it. Families are left to the agent, which knows them better than a prefix list.
215
+ */
216
+ export function panelWarning(panel: ResolvedPanel, config: AdapterConfig): string | undefined {
217
+ const models = new Set(panel.seats.map((seat) => seat.spec?.model ?? PARENT_MODEL));
218
+ const pool = panel.role === "arena-cross-judge-pool";
219
+ // Upstream lets a panel line hold one entry on purpose, so only the judge pool warns at one seat.
220
+ if (models.size !== 1 || (panel.seats.length < 2 && !pool)) return undefined;
221
+ const [model] = [...models];
222
+ const named = model === PARENT_MODEL ? PARENT_MODEL : `\`${model}\``;
223
+ const cause = panel.configured
224
+ ? `its configured entries all name ${named}`
225
+ : `it is unset, and ${PANEL_DEFAULT_ROLES.map((role) => `\`${role}\``).join(" and ")} both resolve to ${named}`;
226
+ return `Every seat of \`${panel.role}\` runs on ${named}, because ${cause}, ${pool ? poolEffect(panel, config, model) : "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.`;
227
+ }
228
+
229
+ /**
230
+ * Arena's judge is never from the session's family. When no pool entry qualifies, upstream tries
231
+ * its default pool, which here is `judgment`, then `general`, before it skips the judge.
232
+ */
233
+ function poolEffect(panel: ResolvedPanel, config: AdapterConfig, model: string | undefined): string {
234
+ const fallback = defaultSeats(config);
235
+ const fallbackModels = new Set(fallback.map((seat) => seat.spec?.model ?? PARENT_MODEL));
236
+ const lead =
237
+ model === PARENT_MODEL
238
+ ? "and an `inherit` entry never judges, so arena"
239
+ : `so a session on the family of \`${model}\` gets no judge from the pool, and arena`;
240
+ const sameFallback = !panel.configured || (fallbackModels.size === 1 && fallbackModels.has(model ?? PARENT_MODEL));
241
+ return sameFallback
242
+ ? `${lead} skips the judge`
243
+ : `${lead} tries its default pool, ${fallback.map(describeSeat).join("; ")}, before it skips the judge`;
244
+ }
245
+
246
+ /** The panels a workflow reads, for its `check` report. */
247
+ export function renderPanels(config: AdapterConfig, roles: readonly string[]): string {
248
+ const lines = roles.map((role) => {
249
+ const panel = resolvePanel(config, role);
250
+ const source = panel.configured ? "configured" : `unset, so ${PANEL_DEFAULT_ROLES.join(", ")}`;
251
+ const warning = panelWarning(panel, config);
252
+ return `- \`${role}\` (${source}): ${panel.seats.map(describeSeat).join("; ")}.${warning ? ` Warning. ${warning}` : ""}`;
253
+ });
254
+ return `Panels this workflow uses, one seat per entry:\n${lines.join("\n")}`;
255
+ }
256
+
137
257
  function describeDelegation(capabilities: readonly CapabilityStatus[]): string {
138
258
  const backend = capabilities.find((capability) => capability.id === "delegation")?.backend;
139
259
  if (!backend) return "none available";
@@ -172,6 +292,8 @@ export function renderCheck(
172
292
  report: PrerequisiteReport,
173
293
  /** The resolved `delegation` capability, named for a workflow that delegates. */
174
294
  delegation?: CapabilityStatus,
295
+ /** The workflow's panels, from `renderPanels`. */
296
+ panels?: string,
175
297
  ): string {
176
298
  const parts = [`\`/skill:${skill.generatedName}\` is tier **${skill.tier}**.`];
177
299
  if (entry.note) parts.push(entry.note);
@@ -190,6 +312,7 @@ export function renderCheck(
190
312
  delegation?.backend ? delegationInstruction(delegation.backend, delegation.tools) : "No delegation tool is usable in this session, so do not delegate.",
191
313
  );
192
314
  }
315
+ if (panels && skill.tier !== "unsupported") parts.push(panels);
193
316
  if (skill.tier === "experimental" && report.ok) {
194
317
  parts.push("Tier `experimental` means parts of this workflow have no Pi equivalent. Expect gaps.");
195
318
  }
package/src/upstream.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { MANIFEST_RELATIVE_PATH } from "./manifest.ts";
@@ -202,7 +202,7 @@ async function reuseCheckout(
202
202
  wanted: string,
203
203
  pluginPath: string,
204
204
  ): Promise<{ commit: string; pluginDir: string } | undefined> {
205
- if (!existsSync(join(dir, ".git"))) return undefined;
205
+ if (!(await isCacheCheckout(git, dir))) return undefined;
206
206
  if ((await git(["cat-file", "-e", `${wanted}^{commit}`], dir)).code !== 0) return undefined;
207
207
 
208
208
  // A read-only cache cannot take the setting. That is fine while its files are already LF, and
@@ -307,15 +307,43 @@ async function headCommit(git: GitRunner, dir: string): Promise<string> {
307
307
  return result.code === 0 ? result.stdout.trim() : "";
308
308
  }
309
309
 
310
+ /**
311
+ * Git discovers ancestor repositories from an empty cache inside a project. A successful
312
+ * rev-parse alone therefore does not authorize config, fetch, or checkout. Adapter caches
313
+ * must own both their worktree and standalone metadata, including the common directory.
314
+ */
315
+ async function isCacheCheckout(git: GitRunner, dir: string): Promise<boolean> {
316
+ const metadata = join(dir, ".git");
317
+ try {
318
+ const entry = lstatSync(metadata);
319
+ if (!entry.isDirectory() || entry.isSymbolicLink()) return false;
320
+ const identity = await git([
321
+ "rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir",
322
+ ], dir);
323
+ if (identity.code !== 0) return false;
324
+ const paths = identity.stdout.trim().split(/\r?\n/);
325
+ if (paths.length !== 3) return false;
326
+ return [dir, metadata, metadata].every((expected, index) => {
327
+ const actual = paths[index];
328
+ return actual !== undefined && relative(realpathSync.native(expected), realpathSync.native(resolve(dir, actual))) === "";
329
+ });
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+
310
335
  async function bootstrap(git: GitRunner, dir: string, repo: string, ref: string, pluginPath: string): Promise<void> {
311
336
  mkdirSync(dir, { recursive: true });
312
- if ((await git(["rev-parse", "--git-dir"], dir)).code === 0) {
337
+ if (await isCacheCheckout(git, dir)) {
313
338
  await git(["remote", "set-url", "origin", repo], dir);
314
339
  } else {
315
340
  // A half-created or damaged cache is disposable. Start it over rather than repairing it.
316
341
  rmSync(dir, { recursive: true, force: true });
317
342
  mkdirSync(dir, { recursive: true });
318
343
  await expect(git(["init", "--quiet", "."], dir), "initialize the adapter cache checkout");
344
+ if (!(await isCacheCheckout(git, dir))) {
345
+ throw new Error(`The pstack adapter cache at ${dir} could not verify ownership of its Git worktree and metadata after initialization. Check directory permissions and inherited Git directory overrides, then start Pi again.`);
346
+ }
319
347
  await expect(git(["remote", "add", "origin", repo], dir), `add ${repo} as the cache remote`);
320
348
  }
321
349
  await pinLineEndings(git, dir);
@@ -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
+ }