@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.
- package/generated/agent-jobs.ts +24 -0
- package/generated/agent-review-message.ts +29 -8
- package/generated/ai/providers/codex-sdk.ts +37 -2
- package/generated/claude-review.ts +46 -44
- package/generated/codex-review.ts +49 -19
- package/generated/external-annotation.ts +78 -20
- package/generated/marker-review.ts +824 -0
- package/generated/review-findings.ts +75 -0
- package/generated/review-profiles.ts +144 -0
- package/generated/review-skill-loader.ts +467 -0
- package/package.json +1 -1
- package/plannotator.html +3 -3
- package/review-editor.html +200 -183
- package/server/agent-jobs.ts +161 -34
- package/server/serverReview.ts +203 -32
|
@@ -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