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/CHANGELOG.md +22 -0
- package/README.md +24 -6
- package/VERSION +1 -1
- package/adapter-skills/setup.md +44 -11
- package/docs/compatibility.md +246 -26
- package/index.ts +55 -6
- package/package.json +2 -1
- package/src/certified.ts +3 -3
- package/src/config.ts +94 -3
- package/src/generate.ts +66 -13
- package/src/registry.ts +48 -9
- package/src/status.ts +127 -4
- package/src/upstream.ts +31 -3
- package/src/vocabulary.ts +123 -0
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)
|
|
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 (!
|
|
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 (
|
|
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
|
+
}
|