@plannotator/pi-extension 0.21.1 → 0.21.2

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.
@@ -0,0 +1,75 @@
1
+ // @generated — DO NOT EDIT. Source: packages/server/review-findings.ts
2
+ import { classifyFindingPlacement } from "./external-annotation.js";
3
+ import { toRelativePath } from "./path-utils.js";
4
+
5
+ /**
6
+ * Shared review-finding model + transform.
7
+ *
8
+ * Claude and the marker engines (Cursor/OpenCode) all emit findings with the
9
+ * same shape — `{ severity, file?, line?, end_line?, description, reasoning }`,
10
+ * nullable file/line — and turn them into annotations the same way (relative
11
+ * path → classifyFindingPlacement → line/whole-file/general). This is the one
12
+ * canonical place for that, so a placement/annotation fix lands once.
13
+ *
14
+ * (Codex is NOT here: its findings carry a different shape — priority +
15
+ * code_location — and it maps separately.)
16
+ */
17
+
18
+ export type ReviewSeverity = "important" | "nit" | "pre_existing";
19
+
20
+ export interface ReviewFinding {
21
+ severity: ReviewSeverity;
22
+ file?: string | null; // null for a general (review-level) comment
23
+ line?: number | null; // null for a whole-file or general comment
24
+ end_line?: number | null;
25
+ description: string;
26
+ reasoning: string;
27
+ }
28
+
29
+ export interface ReviewAnnotationInput {
30
+ source: string;
31
+ filePath: string;
32
+ lineStart: number;
33
+ lineEnd: number;
34
+ type: string;
35
+ side: string;
36
+ scope: string;
37
+ text: string;
38
+ severity: ReviewSeverity;
39
+ reasoning: string;
40
+ author: string;
41
+ }
42
+
43
+ /**
44
+ * Transform severity-style findings into annotation inputs. Routes each finding
45
+ * by what it carries — nothing is dropped: file+line → line comment; file only
46
+ * → whole-file comment; neither → general (review-level) comment.
47
+ */
48
+ export function transformSeverityFindings(
49
+ findings: ReviewFinding[],
50
+ source: string,
51
+ author: string,
52
+ cwd?: string,
53
+ pathTransform?: (path: string) => string,
54
+ ): ReviewAnnotationInput[] {
55
+ return findings.map((f) => {
56
+ const rawFile = typeof f.file === "string" ? f.file : "";
57
+ const filePath = rawFile
58
+ ? (pathTransform ? pathTransform(toRelativePath(rawFile, cwd)) : toRelativePath(rawFile, cwd))
59
+ : "";
60
+ const placement = classifyFindingPlacement(filePath, f.line, f.end_line);
61
+ return {
62
+ source,
63
+ filePath: placement.filePath,
64
+ lineStart: placement.lineStart,
65
+ lineEnd: placement.lineEnd,
66
+ type: "comment",
67
+ side: "new",
68
+ scope: placement.scope,
69
+ text: `[${f.severity}] ${f.description}`,
70
+ severity: f.severity,
71
+ reasoning: f.reasoning,
72
+ author,
73
+ };
74
+ });
75
+ }
@@ -0,0 +1,144 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/review-profiles.ts
2
+ /**
3
+ * Review Profiles — shared contract types + prompt-composition spine.
4
+ *
5
+ * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. The loader that
6
+ * reads custom reviews from disk lives in packages/server/review-skill-loader.ts
7
+ * and maps each curated Agent Skill into a ResolvedReviewProfile that this
8
+ * module's composer renders. Vendored to Pi.
9
+ *
10
+ * A review profile is a named bundle of review intent. A custom review skill
11
+ * fully replaces the provider's system prompt — picking a review runs that
12
+ * review. The built-in default carries no instructions, so the composer falls
13
+ * back to the provider prompt and the default review stays byte-for-byte today's.
14
+ *
15
+ * See docs/custom-reviews.md.
16
+ */
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Where a resolved profile came from. */
23
+ export type ReviewProfileSource = "builtin" | "user";
24
+
25
+ export interface ReviewProfile {
26
+ id: string;
27
+ label: string;
28
+ /** The injected review instructions. */
29
+ instructions: string;
30
+ description?: string;
31
+ }
32
+
33
+ export interface ResolvedReviewProfile extends ReviewProfile {
34
+ source: ReviewProfileSource;
35
+ sourcePath?: string;
36
+ /** True for builtin:default — surfaced to the picker as the pre-selected option. */
37
+ default?: boolean;
38
+ }
39
+
40
+ /** Response shape for `GET /api/agents/review-profiles`. */
41
+ export interface ReviewProfilesResponse {
42
+ profiles: Array<{
43
+ id: string;
44
+ label: string;
45
+ description?: string;
46
+ source: ReviewProfileSource;
47
+ sourcePath?: string;
48
+ default?: boolean;
49
+ }>;
50
+ }
51
+
52
+ /** Reserved id for the built-in default review. */
53
+ export const BUILTIN_DEFAULT_ID = "builtin:default";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Built-in profiles
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * The built-in default review — today's review, preserved. It carries no
61
+ * custom instructions, so the composer falls back to the provider prompt and
62
+ * the default prompt stays byte-for-byte today's.
63
+ */
64
+ export const BUILTIN_DEFAULT_PROFILE: ResolvedReviewProfile = {
65
+ id: BUILTIN_DEFAULT_ID,
66
+ label: "Default",
67
+ instructions: "",
68
+ source: "builtin",
69
+ default: true,
70
+ };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Prompt composition
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * A profile replaces the provider prompt only when it carries instructions and
78
+ * isn't the reserved built-in default. The default (or any instruction-less
79
+ * profile) falls back to the provider prompt, keeping it byte-for-byte today's.
80
+ */
81
+ export function profileHasCustomSection(profile: ResolvedReviewProfile | undefined): boolean {
82
+ return (
83
+ !!profile &&
84
+ profile.id !== BUILTIN_DEFAULT_ID &&
85
+ profile.instructions.trim().length > 0
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Output-contract reminder appended to a custom review skill's prompt. A skill
91
+ * carries its own review methodology but does not know how Plannotator wants
92
+ * results returned — without this, a verdict-style skill collapses good,
93
+ * line-locatable findings into one block. This covers only HOW to report, never
94
+ * WHAT to look for, so it never competes with the skill's own methodology.
95
+ */
96
+ export const REPORTING_INSTRUCTIONS = `## Returning your findings
97
+
98
+ Hand back what you found as separate findings, not as one combined report.
99
+
100
+ - One finding per issue. Don't merge unrelated points into a single entry.
101
+ - Anchor each finding where it belongs:
102
+ - About specific code? Give the file and the line(s), so it attaches to that spot in the diff.
103
+ - About a whole file? Give the file and leave the line out.
104
+ - A review-wide point? Leave out both the file and the line.
105
+ - Always produce the code-specific findings first.
106
+ - If your instructions also ask for a final verdict, summary, or overall judgment, add it as its own review-wide finding with no file and no line. The verdict is in addition to the specific findings, never a replacement for them.
107
+
108
+ If the instructions above told you to produce a particular report layout or document, that was for your own reasoning. For the final result, return findings in the structure described here: the specific code findings, plus any verdict as a separate review-wide finding.`;
109
+
110
+ /**
111
+ * Compose the full review prompt deterministically.
112
+ *
113
+ * Custom review skill:
114
+ * <skill instructions>
115
+ * ## Returning your findings … (output contract, see REPORTING_INSTRUCTIONS)
116
+ * ---
117
+ * <user message>
118
+ *
119
+ * The skill body fully replaces the provider's system prompt — picking a review
120
+ * runs that review. The reporting reminder is appended so the skill's findings
121
+ * come back in Plannotator's shape (line/file/general) instead of one block.
122
+ *
123
+ * Built-in default (or any instruction-less profile):
124
+ * <provider immutable instructions>
125
+ * ---
126
+ * <user message>
127
+ *
128
+ * The default already states its own output contract, so it gets no reminder and
129
+ * stays byte-identical to today's `systemPrompt + "\n\n---\n\n" + userMessage`.
130
+ */
131
+ export function composeReviewPrompt(
132
+ systemPrompt: string,
133
+ profile: ResolvedReviewProfile | undefined,
134
+ userMessage: string,
135
+ ): string {
136
+ if (profileHasCustomSection(profile)) {
137
+ return (
138
+ (profile as ResolvedReviewProfile).instructions.trim() +
139
+ "\n\n" + REPORTING_INSTRUCTIONS +
140
+ "\n\n---\n\n" + userMessage
141
+ );
142
+ }
143
+ return systemPrompt + "\n\n---\n\n" + userMessage;
144
+ }
@@ -0,0 +1,467 @@
1
+ // @generated — DO NOT EDIT. Source: packages/server/review-skill-loader.ts
2
+ /**
3
+ * Review Skill Loader
4
+ *
5
+ * A custom review is a curated Agent Skill. This loader discovers skills in the
6
+ * user's *global* skill roots, filters them to the ones the user explicitly
7
+ * curated in `${PLANNOTATOR_DATA_DIR}/review-skills.json`, and maps each into a
8
+ * ResolvedReviewProfile whose `instructions` is the skill's SKILL.md body.
9
+ *
10
+ * Server-side (node:fs). Vendored to Pi. The runtime-agnostic prompt-composition
11
+ * spine lives in @plannotator/shared/review-profiles; this file only does disk
12
+ * I/O + curation, then hands a ResolvedReviewProfile to that composer.
13
+ *
14
+ * Trust model (v1): global, user-owned roots only (`~/.claude/skills`,
15
+ * `~/.codex/skills`, `~/.config/agents/skills`), honoring the standard env
16
+ * overrides (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`). Project/repo
17
+ * skills are NOT discovered (the fork-trust problem). See docs/custom-reviews.md.
18
+ *
19
+ * Skip-and-log discipline: an unreadable dir / file is skipped with one log
20
+ * line and never throws. Read on each request — no file watching, no cache.
21
+ */
22
+
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ realpathSync,
29
+ statSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { getPlannotatorDataDir } from "./data-dir";
35
+ import {
36
+ BUILTIN_DEFAULT_PROFILE,
37
+ type ResolvedReviewProfile,
38
+ } from "./review-profiles.js";
39
+
40
+ /**
41
+ * Oversized-body bound. A giant SKILL.md would blow up the review prompt; over
42
+ * this length the skill is dropped with a log line and falls through to the
43
+ * built-in default. This is the old MAX_INSTRUCTIONS_LEN value, re-homed here.
44
+ */
45
+ export const MAX_SKILL_BODY_LEN = 20_000;
46
+
47
+ /** Directories never descended during discovery. */
48
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "__pycache__"]);
49
+
50
+ type SkillRoot = "claude" | "codex" | "universal";
51
+
52
+ /** A skill discovered on disk — catalog stage, no body read, no frontmatter read. */
53
+ export interface DiscoveredSkill {
54
+ /** The skill's directory name. */
55
+ name: string;
56
+ /** Absolute path to the skill directory. */
57
+ sourcePath: string;
58
+ /** Absolute path to SKILL.md. */
59
+ skillMdPath: string;
60
+ /** Which global root it came from. */
61
+ root: SkillRoot;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Root resolution
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * The ordered global skill roots, honoring env overrides. First-seen wins on a
70
+ * cross-root name clash, so order matters: Claude → Codex → universal.
71
+ *
72
+ * Roots that resolve (via realpath) to the same on-disk directory are deduped,
73
+ * keeping the first occurrence.
74
+ */
75
+ export function resolveGlobalSkillRoots(): Array<{ dir: string; root: SkillRoot }> {
76
+ // Prefer $HOME (where the user's dotfiles live, and what every other skill
77
+ // tool keys off), falling back to the OS home. homedir() caches at process
78
+ // start and ignores a later HOME, so $HOME is also what makes this testable.
79
+ const home = process.env.HOME?.trim() || homedir();
80
+ const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
81
+ const codexHome = process.env.CODEX_HOME?.trim() || join(home, ".codex");
82
+ // Universal root. Two locations are in the wild: the documented/de-facto
83
+ // ~/.agents/skills (where the installer puts skills and Claude symlinks them)
84
+ // and the XDG path ${XDG_CONFIG_HOME:-~/.config}/agents/skills. Scan both; the
85
+ // realpath dedup below collapses them when they point at the same dir.
86
+ const configHome = process.env.XDG_CONFIG_HOME?.trim() || join(home, ".config");
87
+
88
+ const candidates: Array<{ dir: string; root: SkillRoot }> = [
89
+ { dir: join(claudeHome, "skills"), root: "claude" },
90
+ { dir: join(codexHome, "skills"), root: "codex" },
91
+ { dir: join(home, ".agents", "skills"), root: "universal" },
92
+ { dir: join(configHome, "agents", "skills"), root: "universal" },
93
+ ];
94
+
95
+ // Dedup by realpath so two roots pointing at the same dir (e.g. via a symlink,
96
+ // or CLAUDE_CONFIG_DIR and CODEX_HOME aimed at one place) collapse to one.
97
+ // Keep first occurrence.
98
+ const seen = new Set<string>();
99
+ const roots: Array<{ dir: string; root: SkillRoot }> = [];
100
+ for (const candidate of candidates) {
101
+ let key: string;
102
+ try {
103
+ key = realpathSync(candidate.dir);
104
+ } catch {
105
+ // Dir doesn't exist or is unreadable; key on the literal path so a
106
+ // non-existent root still dedupes against an identical literal.
107
+ key = candidate.dir;
108
+ }
109
+ if (seen.has(key)) continue;
110
+ seen.add(key);
111
+ roots.push(candidate);
112
+ }
113
+ return roots;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Discovery
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** True iff `dir/SKILL.md` exists and is a regular file. */
121
+ function hasSkillMd(dir: string): boolean {
122
+ try {
123
+ return statSync(join(dir, "SKILL.md")).isFile();
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** List immediate subdirectories of `dir` (skipping known noise dirs), or []. */
130
+ function listSubdirs(dir: string): string[] {
131
+ let entries: import("node:fs").Dirent[];
132
+ try {
133
+ entries = readdirSync(dir, { withFileTypes: true });
134
+ } catch (err) {
135
+ console.error(
136
+ `[plannotator] Could not read skill root ${dir}: ${
137
+ err instanceof Error ? err.message : String(err)
138
+ }`,
139
+ );
140
+ return [];
141
+ }
142
+ return entries
143
+ .filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name))
144
+ .map((e) => e.name);
145
+ }
146
+
147
+ /**
148
+ * Discover skills across the global roots. A skill is any directory containing a
149
+ * SKILL.md; its `name` is the directory name (no frontmatter read).
150
+ *
151
+ * Container layout: roots are walked one extra level so the catalog layout
152
+ * `skills/<category>/<skill>/SKILL.md` is found, matching the reference walk.
153
+ * A child dir that itself holds a SKILL.md is taken as the skill and not
154
+ * descended into.
155
+ *
156
+ * Dedup by skill `name` across roots — first-seen wins, ordered Claude → Codex
157
+ * → universal (the same first-seen-wins clash story as the old JSON design).
158
+ */
159
+ export function discoverSkills(): DiscoveredSkill[] {
160
+ const byName = new Map<string, DiscoveredSkill>();
161
+
162
+ const add = (dir: string, root: SkillRoot) => {
163
+ const name = dir.replace(/^.*[\\/]/, "");
164
+ if (byName.has(name)) return; // first-seen wins on a cross-root name clash
165
+ byName.set(name, {
166
+ name,
167
+ sourcePath: dir,
168
+ skillMdPath: join(dir, "SKILL.md"),
169
+ root,
170
+ });
171
+ };
172
+
173
+ for (const { dir: rootDir, root } of resolveGlobalSkillRoots()) {
174
+ if (!existsSync(rootDir)) continue;
175
+
176
+ for (const childName of listSubdirs(rootDir)) {
177
+ const childDir = join(rootDir, childName);
178
+ if (hasSkillMd(childDir)) {
179
+ add(childDir, root);
180
+ continue; // don't descend past a discovered skill
181
+ }
182
+ // Walk one extra level for the `skills/<category>/<skill>/` catalog layout.
183
+ for (const grandName of listSubdirs(childDir)) {
184
+ const grandDir = join(childDir, grandName);
185
+ if (hasSkillMd(grandDir)) add(grandDir, root);
186
+ }
187
+ }
188
+ }
189
+
190
+ return [...byName.values()];
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Body extraction (no frontmatter parsing)
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Return the SKILL.md body. We do NOT parse frontmatter — we strip only the
199
+ * leading `---…---` block (a split, not a parse) so we don't inject YAML noise,
200
+ * and return everything after it. CRLF/BOM safe.
201
+ *
202
+ * No leading `---` block → the whole file is the body.
203
+ */
204
+ export function stripFrontmatter(raw: string): string {
205
+ // Tolerate a UTF-8 BOM and either line ending.
206
+ const text = raw.replace(/^/, "");
207
+ const match = text.match(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/);
208
+ if (!match) return text;
209
+ return text.slice(match[0].length);
210
+ }
211
+
212
+ /**
213
+ * True iff the skill directory carries files beyond SKILL.md — `references/`,
214
+ * `scripts/`, or `assets/` the body may point at by relative path. An unreadable
215
+ * dir is treated as no extra files; never throws.
216
+ */
217
+ function skillHasExtraFiles(sourcePath: string): boolean {
218
+ let entries: string[];
219
+ try {
220
+ entries = readdirSync(sourcePath);
221
+ } catch {
222
+ return false;
223
+ }
224
+ return entries.some((name) => name !== "SKILL.md");
225
+ }
226
+
227
+ /**
228
+ * The one line prepended to a skill's instructions when it carries extra files,
229
+ * pointing the agent at the skill's REAL directory (read-only, no copy). The
230
+ * agent's working directory is the repository under review, not the skill dir,
231
+ * so relative references/scripts/assets must resolve against this absolute base.
232
+ * The agent reads those files on demand (progressive disclosure) straight from
233
+ * where the skill already lives — which it can, since it shares the filesystem.
234
+ */
235
+ function skillFilesPointerLine(skillDir: string): string {
236
+ return `This review skill's files (references, scripts, assets) are at: ${skillDir}\nResolve any relative paths in the instructions below (e.g. references/, scripts/, assets/) against that absolute directory — the working directory is the repository under review, not the skill directory.`;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Curation
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Read the curated skill names from `${dataDir}/review-skills.json`.
245
+ *
246
+ * Schema (v1): `{ version: 1, enabled: string[] }`. `enabled` may be empty.
247
+ * Anything that fails these checks — missing/non-1 `version`, `enabled` not an
248
+ * array of strings, or unparseable JSON — is treated as no curation (zero
249
+ * custom reviews), logged once. Absent file → no curation, silent.
250
+ *
251
+ * Returns the set of enabled names, or `null` when there is no valid curation.
252
+ */
253
+ export function readCuratedSkillNames(): Set<string> | null {
254
+ const path = join(getPlannotatorDataDir(), "review-skills.json");
255
+ if (!existsSync(path)) return null;
256
+
257
+ let parsed: unknown;
258
+ try {
259
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
260
+ } catch (err) {
261
+ console.error(
262
+ `[plannotator] Ignoring malformed review-skills.json: ${
263
+ err instanceof Error ? err.message : String(err)
264
+ }`,
265
+ );
266
+ return null;
267
+ }
268
+
269
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
270
+ console.error("[plannotator] Ignoring review-skills.json: not an object.");
271
+ return null;
272
+ }
273
+ const { version, enabled } = parsed as Record<string, unknown>;
274
+ if (version !== 1) {
275
+ console.error("[plannotator] Ignoring review-skills.json: version must be 1.");
276
+ return null;
277
+ }
278
+ if (!Array.isArray(enabled) || !enabled.every((n) => typeof n === "string")) {
279
+ console.error(
280
+ "[plannotator] Ignoring review-skills.json: `enabled` must be an array of strings.",
281
+ );
282
+ return null;
283
+ }
284
+ return new Set(enabled as string[]);
285
+ }
286
+
287
+ /** A discovered skill plus whether it is currently enabled as a review. */
288
+ export interface CatalogSkill {
289
+ name: string;
290
+ root: SkillRoot;
291
+ sourcePath: string;
292
+ enabled: boolean;
293
+ }
294
+
295
+ /**
296
+ * Every discovered skill, each flagged with whether it is enabled as a review.
297
+ * Drives the "add a review" picker: the user sees all their skills and turns one
298
+ * on.
299
+ */
300
+ export function listAllSkills(): CatalogSkill[] {
301
+ const enabled = readCuratedSkillNames() ?? new Set<string>();
302
+ return discoverSkills().map((s) => ({
303
+ name: s.name,
304
+ root: s.root,
305
+ sourcePath: s.sourcePath,
306
+ enabled: enabled.has(s.name),
307
+ }));
308
+ }
309
+
310
+ /**
311
+ * Enable a skill as a review by adding its name to
312
+ * `${dataDir}/review-skills.json`. Creates the file (and the data dir) if absent,
313
+ * keeps `version: 1`, and dedupes. Returns the updated enabled list.
314
+ *
315
+ * Only a name that matches a real discovered skill is accepted, so curation never
316
+ * points at something that is not there. A malformed existing file is replaced
317
+ * with a clean one (it was already being ignored).
318
+ */
319
+ export function enableReviewSkill(name: string): { enabled: string[] } {
320
+ const known = new Set(discoverSkills().map((s) => s.name));
321
+ if (!known.has(name)) {
322
+ throw new Error(`No skill named "${name}" found in any global skill root.`);
323
+ }
324
+ const current = readCuratedSkillNames() ?? new Set<string>();
325
+ current.add(name);
326
+ const enabled = [...current];
327
+
328
+ const dir = getPlannotatorDataDir();
329
+ mkdirSync(dir, { recursive: true });
330
+ writeFileSync(
331
+ join(dir, "review-skills.json"),
332
+ JSON.stringify({ version: 1, enabled }, null, 2) + "\n",
333
+ );
334
+ return { enabled };
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Load + map to ResolvedReviewProfile
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Map a discovered skill into the existing ResolvedReviewProfile contract so
343
+ * nothing downstream learns the word "skill". The id is built inline as
344
+ * `skill:<name>` (an id-string convention; `source` stays `"user"` and adds no
345
+ * ReviewProfileSource variant). The body is read live at this call.
346
+ *
347
+ * Returns `null` when the body is over the size bound (dropped + logged) so the
348
+ * caller falls through to the built-in default.
349
+ */
350
+ export function resolveSkillProfile(skill: DiscoveredSkill): ResolvedReviewProfile | null {
351
+ let raw: string;
352
+ try {
353
+ raw = readFileSync(skill.skillMdPath, "utf-8");
354
+ } catch (err) {
355
+ console.error(
356
+ `[plannotator] Skipping review skill ${skill.name}: could not read ${
357
+ skill.skillMdPath
358
+ }: ${err instanceof Error ? err.message : String(err)}`,
359
+ );
360
+ return null;
361
+ }
362
+
363
+ const body = stripFrontmatter(raw);
364
+ if (!body.trim()) {
365
+ console.error(
366
+ `[plannotator] Skipping review skill ${skill.name}: SKILL.md body is empty.`,
367
+ );
368
+ return null;
369
+ }
370
+ if (body.length > MAX_SKILL_BODY_LEN) {
371
+ console.error(
372
+ `[plannotator] Skipping review skill ${skill.name}: SKILL.md body exceeds ${MAX_SKILL_BODY_LEN} chars.`,
373
+ );
374
+ return null;
375
+ }
376
+
377
+ // When the skill carries extra files, point the agent at the skill's real
378
+ // directory so its relative references resolve. No copy is made — the agent
379
+ // reads those files live, on demand, from where the skill already lives.
380
+ const instructions = skillHasExtraFiles(skill.sourcePath)
381
+ ? `${skillFilesPointerLine(skill.sourcePath)}\n\n${body}`
382
+ : body;
383
+
384
+ return {
385
+ id: `skill:${skill.name}`,
386
+ label: skill.name,
387
+ instructions,
388
+ source: "user",
389
+ sourcePath: skill.sourcePath,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Discover global skills and filter to the curated set.
395
+ *
396
+ * A discovered skill becomes a curated review iff its `name` is in
397
+ * `review-skills.json.enabled`. Names in `enabled` with no matching discovered
398
+ * skill are dropped with one log line. Absent/malformed curation → empty.
399
+ */
400
+ export function discoverCuratedSkills(): DiscoveredSkill[] {
401
+ const enabled = readCuratedSkillNames();
402
+ if (!enabled || enabled.size === 0) return [];
403
+
404
+ const discovered = discoverSkills();
405
+ const byName = new Map(discovered.map((s) => [s.name, s]));
406
+
407
+ const curated: DiscoveredSkill[] = [];
408
+ for (const name of enabled) {
409
+ const skill = byName.get(name);
410
+ if (skill) {
411
+ curated.push(skill);
412
+ } else {
413
+ console.error(
414
+ `[plannotator] Curated review skill "${name}" not found in any global skill root; skipping.`,
415
+ );
416
+ }
417
+ }
418
+ return curated;
419
+ }
420
+
421
+ /**
422
+ * Resolve the review profile a launch requested, or throw a clear error.
423
+ *
424
+ * The client only sends a reviewProfileId when the user picked a custom review,
425
+ * so a non-default id that doesn't resolve is a real problem — a renamed or
426
+ * removed skill, a stale cookie, a malformed request — not a reason to quietly
427
+ * run the default against the wrong instructions. Explicit selection is
428
+ * authoritative here. Absent or the reserved default id → the built-in default.
429
+ */
430
+ export function resolveRequestedReviewProfile(
431
+ requestedProfileId: string | undefined,
432
+ ): ResolvedReviewProfile {
433
+ if (!requestedProfileId || requestedProfileId === BUILTIN_DEFAULT_PROFILE.id) {
434
+ return BUILTIN_DEFAULT_PROFILE;
435
+ }
436
+ const skill = discoverCuratedSkills().find((s) => `skill:${s.name}` === requestedProfileId);
437
+ if (!skill) {
438
+ throw new Error(
439
+ `Review "${requestedProfileId}" is not available — it may have been renamed or removed. Pick another review.`,
440
+ );
441
+ }
442
+ const resolved = resolveSkillProfile(skill);
443
+ if (!resolved) {
444
+ throw new Error(
445
+ `Review "${skill.name}" could not be loaded — its SKILL.md is unreadable, empty, or too large. Fix the skill or pick another review.`,
446
+ );
447
+ }
448
+ return resolved;
449
+ }
450
+
451
+ /**
452
+ * Load and resolve review profiles from the curated skills + the built-in
453
+ * default. Always returns at least `builtin:default` first.
454
+ *
455
+ * This is the entry the servers call (same shape as the old loader's
456
+ * loadReviewProfiles). Bodies are read here, live — for the discovery endpoint
457
+ * this is harmless (it only reads `id`/`label`/`source`/`sourcePath`); a future
458
+ * catalog-only path can swap in `discoverCuratedSkills()` directly.
459
+ */
460
+ export function loadReviewProfiles(): ResolvedReviewProfile[] {
461
+ const profiles: ResolvedReviewProfile[] = [BUILTIN_DEFAULT_PROFILE];
462
+ for (const skill of discoverCuratedSkills()) {
463
+ const profile = resolveSkillProfile(skill);
464
+ if (profile) profiles.push(profile);
465
+ }
466
+ return profiles;
467
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",