@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.
@@ -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 { AgentJobInfo } from "../generated/agent-jobs.js";
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
- CODEX_REVIEW_SYSTEM_PROMPT,
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
- CLAUDE_REVIEW_PROMPT,
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 = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage;
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 = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
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
- if (job.provider === "codex" && meta.outputPath) {
586
- const output = await parseCodexOutput(meta.outputPath);
587
- if (!output) return;
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
- if (output.findings.length > 0) {
597
- const annotations = transformReviewFindings(
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
- .map(a => ({ ...a, ...jobPrContext, ...(jobDiffScope && { diffScope: jobDiffScope }) }));
605
- const result = externalAnnotations.addAnnotations({ annotations });
606
- if ("error" in result) console.error(`[codex-review] addAnnotations error:`, result.error);
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
- if (job.provider === "claude" && meta.stdout) {
612
- const output = parseClaudeStreamOutput(meta.stdout);
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
- console.error(`[claude-review] Failed to parse output (${meta.stdout.length} bytes, last 200: ${meta.stdout.slice(-200)})`);
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
- const total = output.summary.important + output.summary.nit + output.summary.pre_existing;
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: output.summary.important === 0 ? "Correct" : "Issues Found",
621
- explanation: `${output.summary.important} important, ${output.summary.nit} nit, ${output.summary.pre_existing} pre-existing`,
622
- confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (output.summary.important * 0.2)),
739
+ correctness: hasImportant ? "Issues Found" : "Correct",
740
+ explanation: output.summary.explanation,
741
+ confidence: output.summary.confidence,
623
742
  };
624
743
 
625
- if (output.findings.length > 0) {
626
- const annotations = transformClaudeFindings(
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
- .map(a => ({ ...a, ...jobPrContext, ...(jobDiffScope && { diffScope: jobDiffScope }) }));
633
- const result = externalAnnotations.addAnnotations({ annotations });
634
- if ("error" in result) console.error(`[claude-review] addAnnotations error:`, result.error);
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);