@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
package/server/serverReview.ts
CHANGED
|
@@ -46,7 +46,7 @@ import type { WorktreePool } from "../generated/worktree-pool.js";
|
|
|
46
46
|
|
|
47
47
|
import { createEditorAnnotationHandler } from "./annotations.js";
|
|
48
48
|
import { createAgentJobHandler } from "./agent-jobs.js";
|
|
49
|
-
import type
|
|
49
|
+
import { type AgentJobInfo, REVIEW_OUTPUT_FAILED, markJobReviewFailed } from "../generated/agent-jobs.js";
|
|
50
50
|
import { createExternalAnnotationHandler } from "./external-annotations.js";
|
|
51
51
|
import {
|
|
52
52
|
handleDraftRequest,
|
|
@@ -76,7 +76,7 @@ import {
|
|
|
76
76
|
} from "./pr.js";
|
|
77
77
|
import { getRepoInfo } from "./project.js";
|
|
78
78
|
import {
|
|
79
|
-
|
|
79
|
+
composeCodexReviewPrompt,
|
|
80
80
|
buildCodexCommand,
|
|
81
81
|
generateOutputPath,
|
|
82
82
|
parseCodexOutput,
|
|
@@ -84,12 +84,21 @@ import {
|
|
|
84
84
|
} from "../generated/codex-review.js";
|
|
85
85
|
import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "../generated/agent-review-message.js";
|
|
86
86
|
import {
|
|
87
|
-
|
|
87
|
+
composeClaudeReviewPrompt,
|
|
88
88
|
buildClaudeCommand,
|
|
89
89
|
parseClaudeStreamOutput,
|
|
90
90
|
transformClaudeFindings,
|
|
91
91
|
} from "../generated/claude-review.js";
|
|
92
92
|
import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "../generated/tour-review.js";
|
|
93
|
+
import {
|
|
94
|
+
MARKER_ENGINES,
|
|
95
|
+
composeMarkerReviewPrompt,
|
|
96
|
+
buildMarkerCommand,
|
|
97
|
+
parseMarkerStreamOutput,
|
|
98
|
+
transformMarkerFindings,
|
|
99
|
+
makeMarkerNonce,
|
|
100
|
+
extractMarkerNonce,
|
|
101
|
+
} from "../generated/marker-review.js";
|
|
93
102
|
import {
|
|
94
103
|
WorkspaceReviewSession,
|
|
95
104
|
type WorkspaceDiffType,
|
|
@@ -111,6 +120,11 @@ import {
|
|
|
111
120
|
SemanticDiffResponseCache,
|
|
112
121
|
} from "../generated/semantic-diff.js";
|
|
113
122
|
import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.js";
|
|
123
|
+
import { discoverCuratedSkills, resolveRequestedReviewProfile, listAllSkills, enableReviewSkill } from "../generated/review-skill-loader.js";
|
|
124
|
+
import {
|
|
125
|
+
BUILTIN_DEFAULT_PROFILE,
|
|
126
|
+
type ReviewProfilesResponse,
|
|
127
|
+
} from "../generated/review-profiles.js";
|
|
114
128
|
import {
|
|
115
129
|
canStageFiles,
|
|
116
130
|
detectRemoteDefaultCompareTarget,
|
|
@@ -155,6 +169,9 @@ const piCodeNavRuntime: CodeNavRuntime = {
|
|
|
155
169
|
},
|
|
156
170
|
};
|
|
157
171
|
|
|
172
|
+
// Review ingestion completion semantics (REVIEW_OUTPUT_FAILED,
|
|
173
|
+
// markJobReviewFailed) now live in the shared agent-jobs module.
|
|
174
|
+
|
|
158
175
|
/** Detect if running inside WSL (Windows Subsystem for Linux) */
|
|
159
176
|
function detectWSL(): boolean {
|
|
160
177
|
if (process.platform !== "linux") return false;
|
|
@@ -518,6 +535,15 @@ export async function startReviewServer(options: {
|
|
|
518
535
|
: null;
|
|
519
536
|
const launchPrUrl = prMeta?.url;
|
|
520
537
|
const launchDiffScope = isPRMode ? currentPRDiffScope : undefined;
|
|
538
|
+
|
|
539
|
+
const requestedProfileId =
|
|
540
|
+
typeof config?.reviewProfileId === "string" ? config.reviewProfileId : undefined;
|
|
541
|
+
// Resolve the requested review, or throw a clear error. An unresolvable
|
|
542
|
+
// non-default id (renamed/removed skill, stale cookie, malformed request)
|
|
543
|
+
// never silently downgrades to the default — explicit selection is
|
|
544
|
+
// authoritative at this boundary.
|
|
545
|
+
const reviewProfile = resolveRequestedReviewProfile(requestedProfileId);
|
|
546
|
+
|
|
521
547
|
const diffContext: AgentJobInfo["diffContext"] | undefined = workspacePrompt
|
|
522
548
|
? { mode: String(currentDiffType), worktreePath: null }
|
|
523
549
|
: prMeta
|
|
@@ -537,16 +563,20 @@ export async function startReviewServer(options: {
|
|
|
537
563
|
prMetadata: prMeta,
|
|
538
564
|
config,
|
|
539
565
|
});
|
|
540
|
-
return built ? { ...built, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext } : built;
|
|
566
|
+
return built ? { ...built, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label } : built;
|
|
541
567
|
}
|
|
542
568
|
|
|
569
|
+
// A custom review skill carries its own instructions and becomes the whole
|
|
570
|
+
// prompt; strip the default framing prose from the user message so only the
|
|
571
|
+
// git/PR context remains. The default review keeps today's message verbatim.
|
|
572
|
+
const isCustomReview = reviewProfile.source === "user";
|
|
543
573
|
const userMessage = workspacePrompt
|
|
544
574
|
? buildAgentReviewUserMessageForTarget({
|
|
545
575
|
kind: "workspace",
|
|
546
576
|
patch: currentPatch,
|
|
547
577
|
workspace: workspacePrompt,
|
|
548
|
-
})
|
|
549
|
-
: buildAgentReviewUserMessage(currentPatch, currentDiffType as DiffType, userMessageOptions, prMeta);
|
|
578
|
+
}, isCustomReview)
|
|
579
|
+
: buildAgentReviewUserMessage(currentPatch, currentDiffType as DiffType, userMessageOptions, prMeta, isCustomReview);
|
|
550
580
|
const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review";
|
|
551
581
|
|
|
552
582
|
if (provider === "codex") {
|
|
@@ -554,17 +584,35 @@ export async function startReviewServer(options: {
|
|
|
554
584
|
const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined;
|
|
555
585
|
const fastMode = config?.fastMode === true;
|
|
556
586
|
const outputPath = generateOutputPath();
|
|
557
|
-
const prompt =
|
|
587
|
+
const prompt = composeCodexReviewPrompt(userMessage, reviewProfile);
|
|
558
588
|
const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode });
|
|
559
|
-
return { command, outputPath, prompt, cwd, label: jobLabel, model, reasoningEffort, fastMode: fastMode || undefined, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext };
|
|
589
|
+
return { command, outputPath, prompt, cwd, label: jobLabel, model, reasoningEffort, fastMode: fastMode || undefined, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
|
|
560
590
|
}
|
|
561
591
|
|
|
562
592
|
if (provider === "claude") {
|
|
563
593
|
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
|
|
564
594
|
const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined;
|
|
565
|
-
const prompt =
|
|
595
|
+
const prompt = composeClaudeReviewPrompt(userMessage, reviewProfile);
|
|
566
596
|
const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort);
|
|
567
|
-
return { command, stdinPrompt, prompt, cwd, label: jobLabel, captureStdout: true, model, effort, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext };
|
|
597
|
+
return { command, stdinPrompt, prompt, cwd, label: jobLabel, captureStdout: true, model, effort, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Marker engines (Cursor, OpenCode) — one branch, same shape as Claude.
|
|
601
|
+
// Neither CLI has a schema flag, so composeMarkerReviewPrompt ALWAYS
|
|
602
|
+
// appends the marker-block output contract (even for a custom profile —
|
|
603
|
+
// it's the only thing that makes their prose output parseable). The
|
|
604
|
+
// engine's buildArgv passes the prompt as the trailing positional arg and
|
|
605
|
+
// threads the spawn cwd (--workspace for Cursor, --dir for OpenCode).
|
|
606
|
+
// captureStdout is required: the marker block comes back on stdout NDJSON.
|
|
607
|
+
const markerEngine = MARKER_ENGINES[provider as "cursor" | "opencode"];
|
|
608
|
+
if (markerEngine) {
|
|
609
|
+
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
|
|
610
|
+
// Per-job nonce embedded in the marker contract; recovered from job.prompt
|
|
611
|
+
// at parse time so echoed/quoted bare tags can't be mistaken for the payload.
|
|
612
|
+
const nonce = makeMarkerNonce();
|
|
613
|
+
const prompt = composeMarkerReviewPrompt(reviewProfile, userMessage, nonce);
|
|
614
|
+
const { command } = buildMarkerCommand(markerEngine, prompt, model, cwd);
|
|
615
|
+
return { command, prompt, cwd, label: jobLabel, captureStdout: true, model, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
|
|
568
616
|
}
|
|
569
617
|
|
|
570
618
|
return null;
|
|
@@ -582,9 +630,34 @@ export async function startReviewServer(options: {
|
|
|
582
630
|
prRepo: getDisplayRepo(jobPrMeta),
|
|
583
631
|
} : jobPrUrl ? { prUrl: jobPrUrl } : {};
|
|
584
632
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
633
|
+
// Only tag annotations with a *custom* profile — the default review needs no tag.
|
|
634
|
+
const profileLabel =
|
|
635
|
+
job.reviewProfileId && job.reviewProfileId !== BUILTIN_DEFAULT_PROFILE.id
|
|
636
|
+
? job.reviewProfileLabel
|
|
637
|
+
: undefined;
|
|
638
|
+
|
|
639
|
+
// Map findings onto annotations and ingest. Shared by both engine branches;
|
|
640
|
+
// no-ops on an empty set so a clean (zero-finding) review stays "done".
|
|
641
|
+
const ingest = <T extends object>(transformed: readonly T[], logTag: string) => {
|
|
642
|
+
if (transformed.length === 0) return undefined;
|
|
643
|
+
const annotations = transformed.map((a) => ({
|
|
644
|
+
...a,
|
|
645
|
+
...jobPrContext,
|
|
646
|
+
...(jobDiffScope && { diffScope: jobDiffScope }),
|
|
647
|
+
...(profileLabel && { reviewProfileLabel: profileLabel }),
|
|
648
|
+
}));
|
|
649
|
+
const result = externalAnnotations.addAnnotations({ annotations });
|
|
650
|
+
if ("error" in result) console.error(`[${logTag}] addAnnotations error:`, result.error);
|
|
651
|
+
return result;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
if (job.provider === "codex") {
|
|
655
|
+
const output = meta.outputPath ? await parseCodexOutput(meta.outputPath) : null;
|
|
656
|
+
if (!output) {
|
|
657
|
+
// Process exited 0 but output is missing/unparseable — not a green run.
|
|
658
|
+
markJobReviewFailed(job, REVIEW_OUTPUT_FAILED);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
588
661
|
|
|
589
662
|
const hasBlockingFindings = output.findings.some(f => f.priority !== null && f.priority <= 1);
|
|
590
663
|
job.summary = {
|
|
@@ -593,45 +666,96 @@ export async function startReviewServer(options: {
|
|
|
593
666
|
confidence: output.overall_confidence_score,
|
|
594
667
|
};
|
|
595
668
|
|
|
596
|
-
|
|
597
|
-
|
|
669
|
+
ingest(
|
|
670
|
+
transformReviewFindings(
|
|
598
671
|
output.findings,
|
|
599
672
|
job.source,
|
|
600
673
|
cwd,
|
|
601
674
|
"Codex",
|
|
602
675
|
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
676
|
+
),
|
|
677
|
+
"codex-review",
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (job.provider === "claude") {
|
|
683
|
+
const stdout = meta.stdout ?? "";
|
|
684
|
+
const output = parseClaudeStreamOutput(stdout);
|
|
685
|
+
if (!output) {
|
|
686
|
+
console.error(`[claude-review] Failed to parse output (${stdout.length} bytes, last 200: ${stdout.slice(-200)})`);
|
|
687
|
+
markJobReviewFailed(job, REVIEW_OUTPUT_FAILED);
|
|
688
|
+
return;
|
|
607
689
|
}
|
|
690
|
+
|
|
691
|
+
// Recompute the verdict from the findings we actually render. Nothing is
|
|
692
|
+
// dropped now (un-pinnable findings become file/general comments), so the
|
|
693
|
+
// count reflects reality and the card can never claim more than it shows.
|
|
694
|
+
const transformed = transformClaudeFindings(
|
|
695
|
+
output.findings,
|
|
696
|
+
job.source,
|
|
697
|
+
cwd,
|
|
698
|
+
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
|
|
699
|
+
);
|
|
700
|
+
const counts = { important: 0, nit: 0, pre_existing: 0 };
|
|
701
|
+
for (const a of transformed) counts[a.severity]++;
|
|
702
|
+
const total = counts.important + counts.nit + counts.pre_existing;
|
|
703
|
+
job.summary = {
|
|
704
|
+
correctness: counts.important === 0 ? "Correct" : "Issues Found",
|
|
705
|
+
explanation: `${counts.important} important, ${counts.nit} nit, ${counts.pre_existing} pre-existing`,
|
|
706
|
+
confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (counts.important * 0.2)),
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
ingest(transformed, "claude-review");
|
|
608
710
|
return;
|
|
609
711
|
}
|
|
610
712
|
|
|
611
|
-
|
|
612
|
-
|
|
713
|
+
// --- Marker path (Cursor, OpenCode) ---
|
|
714
|
+
// FAIL-CLOSED: marker output is prompt-enforced (no schema flag), so any
|
|
715
|
+
// missing/malformed/schema/transform/insertion failure must MUTATE the job
|
|
716
|
+
// to failed — NEVER throw (agent-jobs.ts swallows throws, silently leaving
|
|
717
|
+
// an exit-0 job marked done). Mirrors the Tour fail-closed pattern below.
|
|
718
|
+
// Findings carry nullable file/line, classified into line/whole-file/
|
|
719
|
+
// general by transformMarkerFindings — nothing is dropped (same as Claude).
|
|
720
|
+
const markerEngine = MARKER_ENGINES[job.provider as "cursor" | "opencode"];
|
|
721
|
+
if (markerEngine) {
|
|
722
|
+
// Recover the per-job nonce embedded in the prompt; without it no block
|
|
723
|
+
// can be trusted, so parse fails closed below.
|
|
724
|
+
const nonce = extractMarkerNonce(job.prompt ?? "");
|
|
725
|
+
const output = nonce && meta.stdout ? parseMarkerStreamOutput(meta.stdout, markerEngine, nonce) : null;
|
|
613
726
|
if (!output) {
|
|
614
|
-
|
|
727
|
+
job.status = "failed";
|
|
728
|
+
job.error = `${markerEngine.author} review output missing or unparseable (no valid marker JSON).`;
|
|
615
729
|
return;
|
|
616
730
|
}
|
|
617
731
|
|
|
618
|
-
|
|
732
|
+
// Derive the verdict from finding severities (like Claude) rather than
|
|
733
|
+
// trusting the model's free-form `correctness` string. Marker engines
|
|
734
|
+
// have no schema flag, so a model value like "not correct" would be
|
|
735
|
+
// stored verbatim and the detail panel (any string containing "correct"
|
|
736
|
+
// except "incorrect" → green) would invert the displayed result.
|
|
737
|
+
const hasImportant = output.findings.some((f) => f.severity === "important");
|
|
619
738
|
job.summary = {
|
|
620
|
-
correctness:
|
|
621
|
-
explanation:
|
|
622
|
-
confidence:
|
|
739
|
+
correctness: hasImportant ? "Issues Found" : "Correct",
|
|
740
|
+
explanation: output.summary.explanation,
|
|
741
|
+
confidence: output.summary.confidence,
|
|
623
742
|
};
|
|
624
743
|
|
|
625
|
-
|
|
626
|
-
|
|
744
|
+
// Reuse the shared ingest() decoration; add a fail-closed check on result.
|
|
745
|
+
const result = ingest(
|
|
746
|
+
transformMarkerFindings(
|
|
627
747
|
output.findings,
|
|
628
748
|
job.source,
|
|
749
|
+
markerEngine.author,
|
|
629
750
|
cwd,
|
|
630
751
|
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
|
|
631
|
-
)
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
752
|
+
),
|
|
753
|
+
`${markerEngine.id}-review`,
|
|
754
|
+
);
|
|
755
|
+
if (result && "error" in result) {
|
|
756
|
+
job.status = "failed";
|
|
757
|
+
job.error = `${markerEngine.author} annotation insertion failed: ${result.error}`;
|
|
758
|
+
return;
|
|
635
759
|
}
|
|
636
760
|
return;
|
|
637
761
|
}
|
|
@@ -1343,6 +1467,53 @@ export async function startReviewServer(options: {
|
|
|
1343
1467
|
await handleUploadRequest(req, res);
|
|
1344
1468
|
} else if (url.pathname === "/api/agents" && req.method === "GET") {
|
|
1345
1469
|
json(res, { agents: [] });
|
|
1470
|
+
} else if (
|
|
1471
|
+
url.pathname === "/api/agents/review-profiles" &&
|
|
1472
|
+
req.method === "GET"
|
|
1473
|
+
) {
|
|
1474
|
+
// Custom reviews discovery. Reloaded per request, no file watching.
|
|
1475
|
+
// Catalog only — directory listing, no SKILL.md bodies read here.
|
|
1476
|
+
// Bodies are read at launch, for the one selected skill.
|
|
1477
|
+
const body: ReviewProfilesResponse = {
|
|
1478
|
+
profiles: [
|
|
1479
|
+
{
|
|
1480
|
+
id: BUILTIN_DEFAULT_PROFILE.id,
|
|
1481
|
+
label: BUILTIN_DEFAULT_PROFILE.label,
|
|
1482
|
+
source: BUILTIN_DEFAULT_PROFILE.source,
|
|
1483
|
+
default: BUILTIN_DEFAULT_PROFILE.default,
|
|
1484
|
+
},
|
|
1485
|
+
...discoverCuratedSkills().map((s) => ({
|
|
1486
|
+
id: `skill:${s.name}`,
|
|
1487
|
+
label: s.name,
|
|
1488
|
+
source: "user" as const,
|
|
1489
|
+
sourcePath: s.sourcePath,
|
|
1490
|
+
})),
|
|
1491
|
+
],
|
|
1492
|
+
};
|
|
1493
|
+
json(res, body);
|
|
1494
|
+
} else if (url.pathname === "/api/agents/skills" && req.method === "GET") {
|
|
1495
|
+
// All discovered skills for the "add a review" picker, each flagged
|
|
1496
|
+
// with whether it is already enabled.
|
|
1497
|
+
json(res, { skills: listAllSkills() });
|
|
1498
|
+
} else if (url.pathname === "/api/agents/review-skills" && req.method === "POST") {
|
|
1499
|
+
// Enable a skill as a review (curation write to review-skills.json).
|
|
1500
|
+
let name: unknown;
|
|
1501
|
+
try {
|
|
1502
|
+
const body = await parseBody(req);
|
|
1503
|
+
name = body.name;
|
|
1504
|
+
} catch {
|
|
1505
|
+
json(res, { error: "Invalid JSON" }, 400);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1509
|
+
json(res, { error: "`name` is required." }, 400);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
try {
|
|
1513
|
+
json(res, enableReviewSkill(name));
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
json(res, { error: err instanceof Error ? err.message : "Could not enable review." }, 400);
|
|
1516
|
+
}
|
|
1346
1517
|
} else if (url.pathname === "/api/git-add" && req.method === "POST") {
|
|
1347
1518
|
try {
|
|
1348
1519
|
const body = await parseBody(req);
|