@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.
@@ -76,12 +76,23 @@ export interface AgentJobInfo {
76
76
  diffScope?: string;
77
77
  /** Diff context at launch time (see AgentJobDiffContext). */
78
78
  diffContext?: AgentJobDiffContext;
79
+ /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */
80
+ reviewProfileId?: string;
81
+ /** Resolved review profile label — rides on findings so the UI can show a profile tag. */
82
+ reviewProfileLabel?: string;
79
83
  }
80
84
 
81
85
  export interface AgentCapability {
82
86
  id: string;
83
87
  name: string;
84
88
  available: boolean;
89
+ /**
90
+ * Provider-discovered model catalog (currently only Cursor). Best-effort and
91
+ * account-specific — populated from the provider CLI at capability-detection
92
+ * time, empty when discovery fails or the CLI is unauthenticated. The UI
93
+ * drives its model picker from this instead of a hardcoded list.
94
+ */
95
+ models?: { id: string; label: string }[];
85
96
  }
86
97
 
87
98
  export interface AgentCapabilities {
@@ -131,3 +142,16 @@ export function isTerminalStatus(status: AgentJobStatus): boolean {
131
142
  export function jobSource(id: string): string {
132
143
  return "agent-" + id.slice(0, 8);
133
144
  }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Review ingestion completion semantics
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */
151
+ export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings.";
152
+
153
+ /** Flip a job to failed with a calm one-liner (Code Tour precedent). */
154
+ export function markJobReviewFailed(job: AgentJobInfo, error: string): void {
155
+ job.status = "failed";
156
+ job.error = error;
157
+ }
@@ -80,32 +80,45 @@ export function buildWorkspacePromptContextLines(
80
80
  ];
81
81
  }
82
82
 
83
- export function buildAgentReviewUserMessageForTarget(target: AgentReviewTarget): string {
83
+ export function buildAgentReviewUserMessageForTarget(
84
+ target: AgentReviewTarget,
85
+ contextOnly = false,
86
+ ): string {
84
87
  if (target.kind === "workspace") {
85
- return buildWorkspaceReviewUserMessage(target.patch, target.workspace);
88
+ return buildWorkspaceReviewUserMessage(target.patch, target.workspace, contextOnly);
86
89
  }
87
90
  return buildAgentReviewUserMessage(
88
91
  target.patch,
89
92
  target.diffType,
90
93
  target.options,
91
94
  target.kind === "pr" ? target.prMetadata : undefined,
95
+ contextOnly,
92
96
  );
93
97
  }
94
98
 
95
- /** Build the dynamic user message shared by local Claude and Codex review jobs. */
99
+ /**
100
+ * Build the dynamic user message shared by local Claude and Codex review jobs.
101
+ *
102
+ * `contextOnly` strips the "Review… / provide findings" framing prose and keeps
103
+ * only the git/PR context the agent needs to locate the changes. Used by custom
104
+ * review skills, which carry their own instructions and must not inherit the
105
+ * default review's framing. With `contextOnly` off the output is byte-identical
106
+ * to today's prompt.
107
+ */
96
108
  export function buildAgentReviewUserMessage(
97
109
  patch: string,
98
110
  diffType: DiffType,
99
111
  options?: AgentReviewUserMessageOptions,
100
112
  prMetadata?: PRMetadata,
113
+ contextOnly = false,
101
114
  ): string {
102
115
  if (prMetadata) {
103
116
  if (options?.prDiffScope === "full-stack") {
104
117
  return [
105
- `Full-stack review of ${prMetadata.url}`,
118
+ contextOnly ? prMetadata.url : `Full-stack review of ${prMetadata.url}`,
106
119
  "",
107
120
  "This is a stacked PR. The diff below shows ALL accumulated changes from the repository default branch through this PR's head (not just this PR's own layer).",
108
- "Review the complete diff for issues that span the stack.",
121
+ ...(contextOnly ? [] : ["Review the complete diff for issues that span the stack."]),
109
122
  "",
110
123
  "```diff",
111
124
  patch,
@@ -113,6 +126,9 @@ export function buildAgentReviewUserMessage(
113
126
  ].join("\n");
114
127
  }
115
128
  if (options?.hasLocalAccess) {
129
+ // Pure context already (where the checkout is, how to diff against the
130
+ // base) — no "Review… / provide findings" framing to strip, so this is
131
+ // identical for default and custom reviews regardless of contextOnly.
116
132
  return [
117
133
  prMetadata.url,
118
134
  "",
@@ -126,11 +142,14 @@ export function buildAgentReviewUserMessage(
126
142
 
127
143
  const instruction = getLocalDiffInstruction(diffType, options?.defaultBranch);
128
144
  if (instruction) {
145
+ if (contextOnly) {
146
+ return `Changeset: ${instruction.target}.\n${instruction.inspect}`;
147
+ }
129
148
  return `Review ${instruction.target}. ${instruction.inspect} Provide prioritized, actionable findings.`;
130
149
  }
131
150
 
132
151
  return [
133
- "Review the following code changes and provide prioritized findings.",
152
+ contextOnly ? "Code changes:" : "Review the following code changes and provide prioritized findings.",
134
153
  "",
135
154
  "```diff",
136
155
  patch,
@@ -141,10 +160,12 @@ export function buildAgentReviewUserMessage(
141
160
  function buildWorkspaceReviewUserMessage(
142
161
  patch: string,
143
162
  workspace: WorkspaceReviewPromptContext,
163
+ contextOnly = false,
144
164
  ): string {
145
165
  return [
146
- "Review the local workspace changes across multiple nested VCS repositories.",
147
- "",
166
+ ...(contextOnly
167
+ ? []
168
+ : ["Review the local workspace changes across multiple nested VCS repositories.", ""]),
148
169
  ...buildWorkspacePromptContextLines(workspace, { includeReportingInstruction: true }),
149
170
  "",
150
171
  "```diff",
@@ -13,6 +13,8 @@
13
13
 
14
14
  import { buildSystemPrompt, buildEffectivePrompt } from "../context.ts";
15
15
  import { BaseSession } from "../base-session.ts";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
16
18
  import type {
17
19
  AIProvider,
18
20
  AIProviderCapabilities,
@@ -29,6 +31,30 @@ import type {
29
31
  const PROVIDER_NAME = "codex-sdk";
30
32
  const DEFAULT_MODEL = "gpt-5.4";
31
33
 
34
+ type GitWorkTreeProbe = (
35
+ command: string,
36
+ args: string[],
37
+ options: { encoding: "utf8" },
38
+ ) => Promise<{ stdout?: string | Buffer }>;
39
+
40
+ const execFileAsync = promisify(execFile);
41
+
42
+ export async function shouldSkipGitRepoCheck(
43
+ cwd: string,
44
+ probe: GitWorkTreeProbe = execFileAsync,
45
+ ): Promise<boolean> {
46
+ try {
47
+ const result = probe(
48
+ "git",
49
+ ["-C", cwd, "rev-parse", "--is-inside-work-tree"],
50
+ { encoding: "utf8" },
51
+ );
52
+ return String((await result).stdout ?? "").trim() !== "true";
53
+ } catch {
54
+ return true;
55
+ }
56
+ }
57
+
32
58
  // ---------------------------------------------------------------------------
33
59
  // Provider
34
60
  // ---------------------------------------------------------------------------
@@ -58,10 +84,13 @@ export class CodexSDKProvider implements AIProvider {
58
84
  }
59
85
 
60
86
  async createSession(options: CreateSessionOptions): Promise<AISession> {
87
+ const cwd = options.cwd ?? this.config.cwd ?? process.cwd();
88
+ const skipGitRepoCheck = await shouldSkipGitRepoCheck(cwd);
61
89
  return new CodexSDKSession({
62
90
  ...this.baseConfig(options),
63
91
  systemPrompt: buildSystemPrompt(options.context),
64
- cwd: options.cwd ?? this.config.cwd ?? process.cwd(),
92
+ cwd,
93
+ skipGitRepoCheck,
65
94
  parentSessionId: null,
66
95
  });
67
96
  }
@@ -74,10 +103,13 @@ export class CodexSDKProvider implements AIProvider {
74
103
  }
75
104
 
76
105
  async resumeSession(sessionId: string): Promise<AISession> {
106
+ const cwd = this.config.cwd ?? process.cwd();
107
+ const skipGitRepoCheck = await shouldSkipGitRepoCheck(cwd);
77
108
  return new CodexSDKSession({
78
109
  ...this.baseConfig(),
79
110
  systemPrompt: null,
80
- cwd: this.config.cwd ?? process.cwd(),
111
+ cwd,
112
+ skipGitRepoCheck,
81
113
  parentSessionId: null,
82
114
  resumeThreadId: sessionId,
83
115
  });
@@ -124,6 +156,7 @@ interface SessionConfig {
124
156
  maxTurns: number;
125
157
  sandboxMode: "read-only" | "workspace-write" | "danger-full-access";
126
158
  cwd: string;
159
+ skipGitRepoCheck: boolean;
127
160
  parentSessionId: string | null;
128
161
  resumeThreadId?: string;
129
162
  codexExecutablePath?: string;
@@ -174,6 +207,7 @@ class CodexSDKSession extends BaseSession {
174
207
  this._thread = this._codexInstance.resumeThread(this.config.resumeThreadId, {
175
208
  model: this.config.model,
176
209
  workingDirectory: this.config.cwd,
210
+ skipGitRepoCheck: this.config.skipGitRepoCheck,
177
211
  sandboxMode: this.config.sandboxMode,
178
212
  ...(this.config.reasoningEffort && { modelReasoningEffort: this.config.reasoningEffort }),
179
213
  });
@@ -181,6 +215,7 @@ class CodexSDKSession extends BaseSession {
181
215
  this._thread = this._codexInstance.startThread({
182
216
  model: this.config.model,
183
217
  workingDirectory: this.config.cwd,
218
+ skipGitRepoCheck: this.config.skipGitRepoCheck,
184
219
  sandboxMode: this.config.sandboxMode,
185
220
  ...(this.config.reasoningEffort && { modelReasoningEffort: this.config.reasoningEffort }),
186
221
  });
@@ -1,5 +1,14 @@
1
1
  // @generated — DO NOT EDIT. Source: packages/server/claude-review.ts
2
- import { toRelativePath } from "./path-utils.js";
2
+ import {
3
+ composeReviewPrompt,
4
+ type ResolvedReviewProfile,
5
+ } from "./review-profiles.js";
6
+ import {
7
+ transformSeverityFindings,
8
+ type ReviewSeverity,
9
+ type ReviewFinding,
10
+ type ReviewAnnotationInput,
11
+ } from "./review-findings";
3
12
 
4
13
  /**
5
14
  * Claude Code Review Agent — prompt, command builder, and JSONL output parser.
@@ -17,16 +26,10 @@ import { toRelativePath } from "./path-utils.js";
17
26
  // Types
18
27
  // ---------------------------------------------------------------------------
19
28
 
20
- export type ClaudeSeverity = "important" | "nit" | "pre_existing";
21
-
22
- export interface ClaudeFinding {
23
- severity: ClaudeSeverity;
24
- file: string;
25
- line: number;
26
- end_line: number;
27
- description: string;
28
- reasoning: string;
29
- }
29
+ // Claude findings ARE review findings — reuse the one shared shape from
30
+ // review-findings.ts rather than keeping a byte-identical copy that can drift.
31
+ export type ClaudeSeverity = ReviewSeverity;
32
+ export type ClaudeFinding = ReviewFinding;
30
33
 
31
34
  export interface ClaudeReviewOutput {
32
35
  findings: ClaudeFinding[];
@@ -50,9 +53,13 @@ export const CLAUDE_REVIEW_SCHEMA_JSON = JSON.stringify({
50
53
  type: "object",
51
54
  properties: {
52
55
  severity: { type: "string", enum: ["important", "nit", "pre_existing"] },
53
- file: { type: "string" },
54
- line: { type: "integer" },
55
- end_line: { type: "integer" },
56
+ // Nullable, not omitted: keep every property in `required` so the
57
+ // schema is valid under strict structured-output validators too. A
58
+ // whole-file finding sets line/end_line null; a general finding also
59
+ // sets file null.
60
+ file: { type: ["string", "null"] },
61
+ line: { type: ["integer", "null"] },
62
+ end_line: { type: ["integer", "null"] },
56
63
  description: { type: "string" },
57
64
  reasoning: { type: "string" },
58
65
  },
@@ -162,6 +169,10 @@ Step 5: Deduplicate and rank
162
169
  - Within each severity, sort by file path and line number
163
170
 
164
171
  Step 6: Return structured JSON output matching the schema.
172
+ Place each finding by how specific it is: give file and line for a line-level
173
+ issue; give file and set line null for a whole-file issue; set file and line
174
+ null for a general, review-level note. Never invent a line you are unsure of —
175
+ drop to a file or general placement instead of guessing.
165
176
  If no issues are found, return an empty findings array with zeroed summary.
166
177
 
167
178
  ## Hard constraints
@@ -176,6 +187,23 @@ Step 6: Return structured JSON output matching the schema.
176
187
  - Do NOT use gh pr comment or any commenting tool
177
188
  - Your only output is the structured JSON findings`;
178
189
 
190
+ // ---------------------------------------------------------------------------
191
+ // Prompt composition
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Compose Claude's review prompt: the immutable system prompt, the resolved
196
+ * profile's Custom Review Profile section (omitted for builtin:default), then
197
+ * the user review message. For builtin:default / no profile the output is
198
+ * byte-identical to today's `CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage`.
199
+ */
200
+ export function composeClaudeReviewPrompt(
201
+ userMessage: string,
202
+ reviewProfile?: ResolvedReviewProfile,
203
+ ): string {
204
+ return composeReviewPrompt(CLAUDE_REVIEW_PROMPT, reviewProfile, userMessage);
205
+ }
206
+
179
207
  // ---------------------------------------------------------------------------
180
208
  // Command builder
181
209
  // ---------------------------------------------------------------------------
@@ -284,36 +312,10 @@ export function transformClaudeFindings(
284
312
  source: string,
285
313
  cwd?: string,
286
314
  pathTransform?: (path: string) => string,
287
- ): Array<{
288
- source: string;
289
- filePath: string;
290
- lineStart: number;
291
- lineEnd: number;
292
- type: string;
293
- side: string;
294
- scope: string;
295
- text: string;
296
- severity: ClaudeSeverity;
297
- reasoning: string;
298
- author: string;
299
- }> {
300
- return findings
301
- .filter(f => f.file && typeof f.line === "number")
302
- .map(f => ({
303
- source,
304
- filePath: pathTransform
305
- ? pathTransform(toRelativePath(f.file, cwd))
306
- : toRelativePath(f.file, cwd),
307
- lineStart: f.line,
308
- lineEnd: f.end_line ?? f.line,
309
- type: "comment",
310
- side: "new",
311
- scope: "line",
312
- text: `[${f.severity}] ${f.description}`,
313
- severity: f.severity,
314
- reasoning: f.reasoning,
315
- author: "Claude Code",
316
- }));
315
+ ): ReviewAnnotationInput[] {
316
+ // Routing (line / whole-file / general) is shared with the marker engines in
317
+ // review-findings.ts — nothing is dropped; only the author differs.
318
+ return transformSeverityFindings(findings, source, "Claude Code", cwd, pathTransform);
317
319
  }
318
320
 
319
321
  // ---------------------------------------------------------------------------
@@ -12,6 +12,11 @@ import { appendFile, mkdir, unlink, writeFile, readFile } from "node:fs/promises
12
12
  import { existsSync } from "node:fs";
13
13
  import { toRelativePath } from "./path-utils.js";
14
14
  import { getPlannotatorDataDir } from "./data-dir";
15
+ import {
16
+ composeReviewPrompt,
17
+ type ResolvedReviewProfile,
18
+ } from "./review-profiles.js";
19
+ import { classifyFindingPlacement } from "./external-annotation.js";
15
20
 
16
21
  // ---------------------------------------------------------------------------
17
22
  // Debug log — only active when PLANNOTATOR_DEBUG is set
@@ -39,7 +44,7 @@ async function debugLog(label: string, data?: unknown): Promise<void> {
39
44
  // can't read, so we materialize the schema to a real file.
40
45
  // ---------------------------------------------------------------------------
41
46
 
42
- const CODEX_REVIEW_SCHEMA = JSON.stringify({
47
+ export const CODEX_REVIEW_SCHEMA = JSON.stringify({
43
48
  type: "object",
44
49
  properties: {
45
50
  findings: {
@@ -51,12 +56,15 @@ const CODEX_REVIEW_SCHEMA = JSON.stringify({
51
56
  body: { type: "string" },
52
57
  confidence_score: { type: "number" },
53
58
  priority: { type: ["integer", "null"] },
59
+ // Nullable, not omittable: OpenAI strict structured output requires
60
+ // every property to appear in `required`. A whole-file finding sets
61
+ // line_range to null; a general finding sets code_location to null.
54
62
  code_location: {
55
- type: "object",
63
+ type: ["object", "null"],
56
64
  properties: {
57
65
  absolute_file_path: { type: "string" },
58
66
  line_range: {
59
- type: "object",
67
+ type: ["object", "null"],
60
68
  properties: {
61
69
  start: { type: "integer" },
62
70
  end: { type: "integer" },
@@ -152,6 +160,8 @@ At the beginning of the finding title, tag the bug with priority level. For exam
152
160
 
153
161
  Additionally, include a numeric priority field in the JSON output for each finding: set "priority" to 0 for P0, 1 for P1, 2 for P2, or 3 for P3. If a priority cannot be determined, omit the field or use null.
154
162
 
163
+ Place each finding by how specific it is. For a line-level issue, set code_location with the file and a line_range. For a whole-file issue, set code_location with the file path and line_range null. For a general, review-wide point, set code_location null. Do not invent a line range you are unsure of — drop to a whole-file or general placement instead.
164
+
155
165
  At the end of your findings, output an "overall correctness" verdict of whether or not the patch should be considered "correct".
156
166
  Correct implies that existing code and tests will not break, and the patch is free of bugs and other blocking issues.
157
167
  Ignore non-blocking issues such as style, formatting, typos, documentation, and other nits.
@@ -159,6 +169,24 @@ Ignore non-blocking issues such as style, formatting, typos, documentation, and
159
169
  FORMATTING GUIDELINES:
160
170
  The finding description should be one paragraph.`;
161
171
 
172
+ // ---------------------------------------------------------------------------
173
+ // Prompt composition
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Compose Codex's review prompt: the immutable system prompt, the resolved
178
+ * profile's Custom Review Profile section (omitted for builtin:default), then
179
+ * the user review message. For builtin:default / no profile the output is
180
+ * byte-identical to today's
181
+ * `CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage`.
182
+ */
183
+ export function composeCodexReviewPrompt(
184
+ userMessage: string,
185
+ reviewProfile?: ResolvedReviewProfile,
186
+ ): string {
187
+ return composeReviewPrompt(CODEX_REVIEW_SYSTEM_PROMPT, reviewProfile, userMessage);
188
+ }
189
+
162
190
  // ---------------------------------------------------------------------------
163
191
  // Command builder
164
192
  // ---------------------------------------------------------------------------
@@ -213,7 +241,7 @@ export function generateOutputPath(): string {
213
241
 
214
242
  export interface CodexCodeLocation {
215
243
  absolute_file_path: string;
216
- line_range: { start: number; end: number };
244
+ line_range?: { start: number; end: number } | null; // null for a whole-file comment
217
245
  }
218
246
 
219
247
  export interface CodexFinding {
@@ -221,7 +249,7 @@ export interface CodexFinding {
221
249
  body: string;
222
250
  confidence_score: number;
223
251
  priority: number | null;
224
- code_location: CodexCodeLocation;
252
+ code_location?: CodexCodeLocation | null; // null for a general (review-level) comment
225
253
  }
226
254
 
227
255
  export interface CodexReviewOutput {
@@ -296,25 +324,27 @@ export function transformReviewFindings(
296
324
  author?: string,
297
325
  pathTransform?: (path: string) => string,
298
326
  ): ReviewAnnotationInput[] {
299
- const annotations = findings
300
- .filter((f) =>
301
- f.code_location?.absolute_file_path &&
302
- typeof f.code_location?.line_range?.start === "number" &&
303
- typeof f.code_location?.line_range?.end === "number"
304
- )
305
- .map((f) => ({
327
+ // Route every finding by what it carries — nothing is dropped. No usable file
328
+ // becomes a general comment; a file without a line range, a whole-file comment.
329
+ const annotations = findings.map((f) => {
330
+ const loc = f.code_location;
331
+ const rawFile = loc && typeof loc.absolute_file_path === "string" ? loc.absolute_file_path : "";
332
+ const filePath = rawFile
333
+ ? (pathTransform ? pathTransform(toRelativePath(rawFile, cwd)) : toRelativePath(rawFile, cwd))
334
+ : "";
335
+ const placement = classifyFindingPlacement(filePath, loc?.line_range?.start, loc?.line_range?.end);
336
+ return {
306
337
  source,
307
- filePath: pathTransform
308
- ? pathTransform(toRelativePath(f.code_location.absolute_file_path, cwd))
309
- : toRelativePath(f.code_location.absolute_file_path, cwd),
310
- lineStart: f.code_location.line_range.start,
311
- lineEnd: f.code_location.line_range.end,
338
+ filePath: placement.filePath,
339
+ lineStart: placement.lineStart,
340
+ lineEnd: placement.lineEnd,
312
341
  type: "comment",
313
342
  side: "new",
314
- scope: "line",
343
+ scope: placement.scope,
315
344
  text: `${f.title}\n\n${f.body}`.trim(),
316
345
  author: author ?? "Review Agent",
317
- }));
346
+ };
347
+ });
318
348
 
319
349
  debugLog("TRANSFORM_FINDINGS", {
320
350
  inputCount: findings.length,
@@ -191,14 +191,53 @@ interface ReviewAnnotation {
191
191
  createdAt: number;
192
192
  author?: string;
193
193
  source?: string;
194
- // Agent review metadata (optional — only set by Claude review findings)
194
+ // Agent review metadata (optional — only set by agent review findings)
195
195
  severity?: string; // "important" | "nit" | "pre_existing"
196
196
  reasoning?: string; // Validation chain explaining how the issue was confirmed
197
+ reviewProfileLabel?: string; // Custom review profile that produced this finding
197
198
  }
198
199
 
199
200
  const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"];
200
201
  const VALID_SIDES = ["old", "new"];
201
- const VALID_SCOPES = ["line", "file"];
202
+ const VALID_SCOPES = ["line", "file", "general"];
203
+
204
+ /** A review finding's placement, derived from what it carries. */
205
+ export type FindingPlacement = {
206
+ scope: "line" | "file" | "general";
207
+ filePath: string;
208
+ lineStart: number;
209
+ lineEnd: number;
210
+ };
211
+
212
+ /**
213
+ * Classify an agent review finding by what it carries, so nothing is dropped:
214
+ * file + a usable line → a line comment
215
+ * file, no line → a whole-file comment
216
+ * neither → a general (review-level) comment
217
+ *
218
+ * For file and general placements the line is 0; for general the path is "".
219
+ * Consumers branch on `scope`, never on the sentinel values.
220
+ */
221
+ export function classifyFindingPlacement(
222
+ filePath: string,
223
+ lineStart: number | null | undefined,
224
+ lineEnd: number | null | undefined,
225
+ ): FindingPlacement {
226
+ const hasFile = filePath.length > 0;
227
+ const hasLine = typeof lineStart === "number";
228
+ if (hasFile && hasLine) {
229
+ return {
230
+ scope: "line",
231
+ filePath,
232
+ lineStart,
233
+ lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart,
234
+ };
235
+ }
236
+ if (hasFile) {
237
+ return { scope: "file", filePath, lineStart: 0, lineEnd: 0 };
238
+ }
239
+ return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 };
240
+ }
202
241
 
203
242
  export function transformReviewInput(
204
243
  body: unknown,
@@ -213,14 +252,40 @@ export function transformReviewInput(
213
252
  const source = requireString(obj, "source", i);
214
253
  if (typeof source !== "string") return source;
215
254
 
216
- const filePath = requireString(obj, "filePath", i);
217
- if (typeof filePath !== "string") return filePath;
218
-
219
- if (typeof obj.lineStart !== "number") {
220
- return { error: `annotations[${i}] missing required "lineStart" field` };
255
+ // scope: optional, defaults to "line"
256
+ const scope = typeof obj.scope === "string" ? obj.scope : "line";
257
+ if (!VALID_SCOPES.includes(scope)) {
258
+ return {
259
+ error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`,
260
+ };
221
261
  }
222
- if (typeof obj.lineEnd !== "number") {
223
- return { error: `annotations[${i}] missing required "lineEnd" field` };
262
+
263
+ // Location requirements depend on scope:
264
+ // line → filePath + lineStart + lineEnd required. A finding that claims
265
+ // a line must carry one, so a broken line finding is rejected
266
+ // rather than quietly passing as a vaguer comment.
267
+ // file → filePath required; line ignored (defaults to 0).
268
+ // general → no file, no line (review-level; defaults to "" / 0).
269
+ let filePath = "";
270
+ let lineStart = 0;
271
+ let lineEnd = 0;
272
+ if (scope !== "general") {
273
+ const fp = requireString(obj, "filePath", i);
274
+ if (typeof fp !== "string") return fp;
275
+ filePath = fp;
276
+ if (scope === "line") {
277
+ if (typeof obj.lineStart !== "number") {
278
+ return { error: `annotations[${i}] missing required "lineStart" field` };
279
+ }
280
+ if (typeof obj.lineEnd !== "number") {
281
+ return { error: `annotations[${i}] missing required "lineEnd" field` };
282
+ }
283
+ lineStart = obj.lineStart;
284
+ lineEnd = obj.lineEnd;
285
+ } else {
286
+ lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0;
287
+ lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0;
288
+ }
224
289
  }
225
290
 
226
291
  // side: optional, defaults to "new"
@@ -239,14 +304,6 @@ export function transformReviewInput(
239
304
  };
240
305
  }
241
306
 
242
- // scope: optional, defaults to "line"
243
- const scope = typeof obj.scope === "string" ? obj.scope : "line";
244
- if (!VALID_SCOPES.includes(scope)) {
245
- return {
246
- error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`,
247
- };
248
- }
249
-
250
307
  // Must have at least text or suggestedCode
251
308
  if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") {
252
309
  return {
@@ -259,8 +316,8 @@ export function transformReviewInput(
259
316
  type,
260
317
  scope,
261
318
  filePath,
262
- lineStart: obj.lineStart,
263
- lineEnd: obj.lineEnd,
319
+ lineStart,
320
+ lineEnd,
264
321
  side,
265
322
  text: typeof obj.text === "string" ? obj.text : undefined,
266
323
  suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined,
@@ -268,9 +325,10 @@ export function transformReviewInput(
268
325
  createdAt: Date.now(),
269
326
  author: typeof obj.author === "string" ? obj.author : undefined,
270
327
  source,
271
- // Agent review metadata (optional — only set by Claude review findings)
328
+ // Agent review metadata (optional — only set by agent review findings)
272
329
  ...(typeof obj.severity === "string" && { severity: obj.severity }),
273
330
  ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }),
331
+ ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }),
274
332
  });
275
333
  }
276
334