@plannotator/pi-extension 0.21.1 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,824 @@
1
+ // @generated — DO NOT EDIT. Source: packages/server/marker-review.ts
2
+ import {
3
+ profileHasCustomSection,
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";
12
+
13
+ /**
14
+ * Marker Review Engines — the shared machinery for review CLIs that expose NO
15
+ * schema-validation flag, so their final review output is prose. Cursor (the
16
+ * `agent` binary) and OpenCode (`opencode run`) are the two. Because they can't
17
+ * be told to emit validated structured output, they are instead told to emit a
18
+ * marker-delimited JSON block, and we extract the LAST complete block from the
19
+ * reconstructed canonical text.
20
+ *
21
+ * Everything else — the finding model (nullable file/line/end_line, classified
22
+ * into line/whole-file/general by classifyFindingPlacement), custom review
23
+ * profiles, and the transform into external annotations — is IDENTICAL to
24
+ * Claude/Codex. The ONLY per-engine differences are captured in the
25
+ * `MarkerEngine` descriptor: the binary, how to build argv, how to pull text
26
+ * out of one stream event, how to discover models, and how to format a live log
27
+ * line. There is exactly ONE copy of every shared piece below.
28
+ *
29
+ * PI-SAFETY: This file is vendored into the Pi (Node) build via vendor.sh, so it
30
+ * MUST contain ZERO Bun.* / Bun-only APIs. The model-discovery SPAWN lives in
31
+ * each runtime's agent-jobs adapter; this module only provides parseModels +
32
+ * buildArgv + modelsArgv (pure data/functions).
33
+ */
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Per-job marker — the JSON payload is delimited by tags carrying a random,
37
+ // per-job nonce: <plannotator-review-json:NONCE> … </plannotator-review-json:NONCE>.
38
+ //
39
+ // Why the nonce: in review mode the diff (and the model's own prose) is
40
+ // untrusted text that routinely contains the bare string "plannotator-review-json"
41
+ // — e.g. when reviewing this very module, or when the model echoes the contract
42
+ // example. A static tag let those echoed mentions be mistaken for the real
43
+ // delimiter, so extraction grabbed prose instead of the payload and valid
44
+ // reviews failed. The nonce is generated at prompt-build time, embedded in the
45
+ // output contract the model copies, and recovered from the stored prompt at
46
+ // parse time, so only the model's genuine payload can match.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const MARKER_TAG = "plannotator-review-json";
50
+
51
+ /** Open delimiter for a job's nonce. */
52
+ export function markerOpen(nonce: string): string {
53
+ return `<${MARKER_TAG}:${nonce}>`;
54
+ }
55
+ /** Close delimiter for a job's nonce. */
56
+ export function markerClose(nonce: string): string {
57
+ return `</${MARKER_TAG}:${nonce}>`;
58
+ }
59
+
60
+ /** Generate an unguessable per-job marker nonce (matches html-assets token style). */
61
+ export function makeMarkerNonce(): string {
62
+ return "pn" + crypto.randomUUID().replace(/-/g, "").slice(0, 12);
63
+ }
64
+
65
+ /**
66
+ * Recover the nonce embedded in a composed prompt's marker contract. Returns
67
+ * null when absent — the parser then fails closed, since no payload can be
68
+ * trusted without the expected delimiter.
69
+ */
70
+ export function extractMarkerNonce(prompt: string): string | null {
71
+ const m = new RegExp(`<${MARKER_TAG}:(pn[0-9a-f]{12})>`).exec(prompt);
72
+ return m ? m[1] : null;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Finding model — IDENTICAL to ClaudeFinding: nullable file/line/end_line so a
77
+ // finding can be a line issue (file + line), a whole-file issue (file, line
78
+ // null), or a general review-level note (both null). Nothing is dropped.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ // Marker findings ARE review findings — reuse the one shared shape from
82
+ // review-findings.ts (this module already imports its transform) rather than
83
+ // keeping a byte-identical copy that could silently diverge.
84
+ export type MarkerSeverity = ReviewSeverity;
85
+ export type MarkerFinding = ReviewFinding;
86
+
87
+ export interface MarkerReviewSummary {
88
+ correctness: string;
89
+ explanation: string;
90
+ confidence: number;
91
+ }
92
+
93
+ export interface MarkerReviewOutput {
94
+ findings: MarkerFinding[];
95
+ summary: MarkerReviewSummary;
96
+ }
97
+
98
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set([
99
+ "important",
100
+ "nit",
101
+ "pre_existing",
102
+ ]);
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Schema validator — hand-rolled (no Ajv): marker output is prompt-enforced, so
106
+ // validation is the floor that turns prose back into a trusted object. Mirrors
107
+ // the claude-review.ts schema, which uses type [string,null] for file/line/
108
+ // end_line: a missing or null placement is VALID (whole-file / general note).
109
+ // Empty findings are valid as long as the JSON is valid and the summary is.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Read a line coordinate that may be a finite integer, null, or absent. The
114
+ * marker block is prompt-enforced, so a fractional/NaN/Infinity line is garbage
115
+ * we will not trust as an annotation coordinate — it is rejected here.
116
+ */
117
+ function optionalNullableInteger(value: unknown): { ok: true; value: number | null } | { ok: false } {
118
+ if (value === undefined || value === null) return { ok: true, value: null };
119
+ if (typeof value === "number" && Number.isInteger(value)) return { ok: true, value };
120
+ return { ok: false };
121
+ }
122
+
123
+ /** Read a value that may be a string, null, or absent — anything else is invalid. */
124
+ function optionalNullableString(value: unknown): { ok: true; value: string | null } | { ok: false } {
125
+ if (value === undefined || value === null) return { ok: true, value: null };
126
+ if (typeof value === "string") return { ok: true, value };
127
+ return { ok: false };
128
+ }
129
+
130
+ /**
131
+ * Validate a parsed object against the marker review schema. Returns the typed
132
+ * output, or null if the shape is invalid. file/line/end_line are nullable —
133
+ * absent or null is accepted (whole-file or general findings).
134
+ */
135
+ export function validateMarkerReviewOutput(parsed: unknown): MarkerReviewOutput | null {
136
+ if (!parsed || typeof parsed !== "object") return null;
137
+ const obj = parsed as Record<string, unknown>;
138
+
139
+ if (!Array.isArray(obj.findings)) return null;
140
+
141
+ const summary = obj.summary;
142
+ if (!summary || typeof summary !== "object") return null;
143
+ const s = summary as Record<string, unknown>;
144
+ if (typeof s.correctness !== "string") return null;
145
+ if (typeof s.explanation !== "string") return null;
146
+ if (typeof s.confidence !== "number" || !Number.isFinite(s.confidence)) return null;
147
+
148
+ const findings: MarkerFinding[] = [];
149
+ for (const raw of obj.findings) {
150
+ if (!raw || typeof raw !== "object") return null;
151
+ const f = raw as Record<string, unknown>;
152
+
153
+ if (typeof f.severity !== "string" || !VALID_SEVERITIES.has(f.severity)) return null;
154
+ if (typeof f.description !== "string") return null;
155
+
156
+ const file = optionalNullableString(f.file);
157
+ if (!file.ok) return null;
158
+ const line = optionalNullableInteger(f.line);
159
+ if (!line.ok) return null;
160
+ const endLine = optionalNullableInteger(f.end_line);
161
+ if (!endLine.ok) return null;
162
+
163
+ findings.push({
164
+ severity: f.severity as MarkerSeverity,
165
+ file: file.value,
166
+ line: line.value,
167
+ end_line: endLine.value,
168
+ description: f.description,
169
+ reasoning: typeof f.reasoning === "string" ? f.reasoning : "",
170
+ });
171
+ }
172
+
173
+ return {
174
+ findings,
175
+ summary: {
176
+ correctness: s.correctness,
177
+ explanation: s.explanation,
178
+ // Clamp to [0,1] — the model occasionally reports out-of-range confidence.
179
+ confidence: Math.max(0, Math.min(1, s.confidence)),
180
+ },
181
+ };
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // MarkerEngine descriptor — the ONLY per-engine surface. Six fields.
186
+ // ---------------------------------------------------------------------------
187
+
188
+ export interface MarkerModel {
189
+ id: string;
190
+ label: string;
191
+ }
192
+
193
+ /** One parsed stream event, opaque to the shared reducer (each engine reads it). */
194
+ export type MarkerStreamEvent = Record<string, unknown>;
195
+
196
+ export interface MarkerEngine {
197
+ /** Stable engine id — also the provider id used by the server. */
198
+ id: "cursor" | "opencode";
199
+ /** Display name for the capabilities/provider listing (e.g. "Cursor CLI"). */
200
+ name: string;
201
+ /** The CLI binary to spawn (NOTE: cursor's binary is `agent`). */
202
+ binary: string;
203
+ /** Author string stamped on every annotation this engine produces. */
204
+ author: string;
205
+ /** Build the full argv (binary + flags + trailing prompt) for a review run. */
206
+ buildArgv: (prompt: string, model?: string, cwd?: string) => string[];
207
+ /** Pull readable text out of one parsed stream event, or null if none. */
208
+ extractText: (event: MarkerStreamEvent) => string | null;
209
+ /** Argv (after the binary) for model discovery, e.g. ["models"]. */
210
+ modelsArgv: string[];
211
+ /** Parse the model-discovery stdout into a catalog. */
212
+ parseModels: (stdout: string) => MarkerModel[];
213
+ /** Format one stream line for the live log, or null to skip it. */
214
+ formatLogEvent: (event: MarkerStreamEvent) => string | null;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Cursor engine helpers
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * A Cursor partial-output assistant event is a real new text delta only when
223
+ * `timestamp_ms` is present AND `model_call_id` is absent. Every other assistant
224
+ * flush is a duplicate re-emission (pre-tool-call flush or end-of-turn flush).
225
+ */
226
+ function cursorIsRealAssistantDelta(event: MarkerStreamEvent): boolean {
227
+ return event.timestamp_ms !== undefined && event.model_call_id === undefined;
228
+ }
229
+
230
+ /** Pull readable text out of a Cursor assistant event's content (string or parts). */
231
+ function cursorAssistantText(event: MarkerStreamEvent): string {
232
+ if (typeof event.text === "string") return event.text;
233
+ const message = event.message as { content?: unknown } | undefined;
234
+ const content = message?.content;
235
+ if (typeof content === "string") return content;
236
+ if (Array.isArray(content)) {
237
+ return content
238
+ .filter((p): p is { type?: string; text?: string } => !!p && typeof p === "object")
239
+ .filter((p) => p.type === "text" && typeof p.text === "string")
240
+ .map((p) => p.text as string)
241
+ .join("");
242
+ }
243
+ return "";
244
+ }
245
+
246
+ /**
247
+ * Cursor `extractText` for the canonical-text reducer: append a real new delta
248
+ * (timestamp_ms present, model_call_id absent). The no-timestamp branch KEEPS
249
+ * the end-of-turn flush — it covers both partial-streaming-disabled output (the
250
+ * full message arrives once) and the enabled-mode final flush, and is a
251
+ * parse-robustness safety net (extractLastMarkerBlock takes the LAST block, so a
252
+ * duplicate is harmless). result events carry the final text too.
253
+ *
254
+ * This is deliberately MORE lenient than cursorFormatLogEvent, which drops the
255
+ * flush so live logs don't repeat the whole assistant output. Do not unify them.
256
+ */
257
+ function cursorExtractText(event: MarkerStreamEvent): string | null {
258
+ if (event.type === "assistant") {
259
+ if (event.timestamp_ms !== undefined) {
260
+ if (cursorIsRealAssistantDelta(event)) return cursorAssistantText(event);
261
+ return null;
262
+ }
263
+ if (event.model_call_id === undefined) return cursorAssistantText(event);
264
+ return null;
265
+ }
266
+ if (event.type === "result") {
267
+ if (typeof event.result === "string") return event.result;
268
+ if (typeof event.text === "string") return event.text;
269
+ return null;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * Parse `agent models` / `agent --list-models` output into a model catalog. The
276
+ * CLI prints one model per line as `<id> - <Label>`, wrapped by an "Available
277
+ * models" header and a "Tip: ..." footer. Returns [] when no model lines are
278
+ * present (e.g. unauthenticated: "No models available...").
279
+ */
280
+ function cursorParseModels(stdout: string): MarkerModel[] {
281
+ if (!stdout) return [];
282
+ const models: MarkerModel[] = [];
283
+ const seen = new Set<string>();
284
+ for (const rawLine of stdout.split("\n")) {
285
+ const line = rawLine.trim();
286
+ // `id - Label` — id is a single whitespace-free token; the separator is
287
+ // " - " with surrounding spaces (model ids contain hyphens but never " - ").
288
+ const match = /^(\S+)\s+-\s+(.+)$/.exec(line);
289
+ if (!match) continue;
290
+ const id = match[1];
291
+ const label = match[2].trim();
292
+ if (!id || seen.has(id)) continue;
293
+ seen.add(id);
294
+ models.push({ id, label });
295
+ }
296
+ return models;
297
+ }
298
+
299
+ /**
300
+ * Format one Cursor `stream-json` event for the LiveLogViewer. Applies the same
301
+ * partial-output dedup rule as the reducer: an assistant delta is shown only
302
+ * when `timestamp_ms` is present and `model_call_id` is absent.
303
+ */
304
+ function cursorFormatLogEvent(event: MarkerStreamEvent): string | null {
305
+ switch (event.type) {
306
+ case "system": {
307
+ if (event.subtype === "init") {
308
+ const model = typeof event.model === "string" ? event.model : undefined;
309
+ const sessionId = typeof event.session_id === "string" ? event.session_id : undefined;
310
+ const bits = ["[init]"];
311
+ if (model) bits.push(`model=${model}`);
312
+ if (sessionId) bits.push(`session=${sessionId}`);
313
+ return bits.join(" ");
314
+ }
315
+ return null;
316
+ }
317
+ case "assistant": {
318
+ if (!cursorIsRealAssistantDelta(event)) return null;
319
+ const text = cursorAssistantText(event);
320
+ return text ? text : null;
321
+ }
322
+ case "tool_call": {
323
+ const name = typeof event.name === "string" ? event.name : "tool";
324
+ if (event.subtype === "completed") {
325
+ return `[${name}] completed`;
326
+ }
327
+ const args =
328
+ typeof event.args === "string"
329
+ ? event.args.slice(0, 100)
330
+ : event.args !== undefined
331
+ ? JSON.stringify(event.args).slice(0, 100)
332
+ : "";
333
+ return `[${name}] ${args}`.trimEnd();
334
+ }
335
+ case "result": {
336
+ const duration =
337
+ typeof event.duration_ms === "number" ? `${event.duration_ms}ms` : undefined;
338
+ const requestId = typeof event.request_id === "string" ? event.request_id : undefined;
339
+ const bits = ["[result]"];
340
+ if (duration) bits.push(duration);
341
+ if (requestId) bits.push(`request=${requestId}`);
342
+ return bits.length > 1 ? bits.join(" ") : null;
343
+ }
344
+ default:
345
+ return null;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Build the `agent -p` command. NOTE the binary is `agent`, NOT `cursor`.
351
+ *
352
+ * Read-only posture comes entirely from `--mode ask` + `--sandbox enabled` and
353
+ * the absence of `--force`/`--yolo`. `--trust` is required in headless print
354
+ * mode: without it Cursor stops on an interactive workspace-trust prompt that a
355
+ * background job can never answer. The prompt is the trailing positional arg —
356
+ * `agent` reads task text from argv, not stdin. `--model` is omitted when the
357
+ * model is `Auto`/empty so Cursor uses its default model selection. `--workspace`
358
+ * is set to the launch cwd when provided.
359
+ */
360
+ function cursorBuildArgv(prompt: string, model?: string, cwd?: string): string[] {
361
+ // `auto` is Cursor's default model id — omit --model so the CLI chooses.
362
+ const useModel = !!model && model.toLowerCase() !== "auto";
363
+ return [
364
+ "agent",
365
+ "-p",
366
+ "--mode",
367
+ "ask",
368
+ "--output-format",
369
+ "stream-json",
370
+ "--stream-partial-output",
371
+ "--trust",
372
+ ...(cwd ? ["--workspace", cwd] : []),
373
+ "--sandbox",
374
+ "enabled",
375
+ ...(useModel ? ["--model", model] : []),
376
+ // Prompt is the trailing positional arg — agent reads it from argv, not stdin.
377
+ prompt,
378
+ ];
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // OpenCode engine helpers
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * OpenCode `extractText`: the assistant text arrives in `type: "text"` events
387
+ * carrying `part.text` (each finalized once). No partial-output dedup is needed —
388
+ * just return the text part. tool_use / step_start / step_finish / reasoning /
389
+ * error events carry no review text.
390
+ */
391
+ function opencodeExtractText(event: MarkerStreamEvent): string | null {
392
+ if (event.type === "text") {
393
+ const part = event.part as { text?: unknown } | undefined;
394
+ if (typeof part?.text === "string") return part.text;
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Parse `opencode models` output into a model catalog. The CLI prints one model
401
+ * id per line as `provider/model`. Returns [] when there are no model lines
402
+ * (e.g. unauthenticated / no providers configured).
403
+ */
404
+ function opencodeParseModels(stdout: string): MarkerModel[] {
405
+ if (!stdout) return [];
406
+ const models: MarkerModel[] = [];
407
+ const seen = new Set<string>();
408
+ for (const rawLine of stdout.split("\n")) {
409
+ const id = rawLine.trim();
410
+ // A model id is `provider/model` and may carry extra `/` segments (e.g.
411
+ // OpenRouter's `openrouter/deepseek/deepseek-chat-v3`): a leading provider,
412
+ // a slash, then a non-empty remainder, all whitespace-free.
413
+ if (!id || /\s/.test(id) || !/^[^/\s]+\/\S+$/.test(id)) continue;
414
+ if (seen.has(id)) continue;
415
+ seen.add(id);
416
+ models.push({ id, label: id });
417
+ }
418
+ return models;
419
+ }
420
+
421
+ /**
422
+ * Format one `opencode run --format json` event for the LiveLogViewer. Returns a
423
+ * human-readable string, or null if the event should be skipped.
424
+ */
425
+ function opencodeFormatLogEvent(event: MarkerStreamEvent): string | null {
426
+ const part = event.part as
427
+ | { text?: unknown; tool?: unknown; state?: { status?: unknown } }
428
+ | undefined;
429
+ switch (event.type) {
430
+ case "text": {
431
+ const text = typeof part?.text === "string" ? part.text.trim() : "";
432
+ return text ? text : null;
433
+ }
434
+ case "tool_use": {
435
+ const tool = typeof part?.tool === "string" ? part.tool : "tool";
436
+ const status = typeof part?.state?.status === "string" ? part.state.status : "";
437
+ return `[${tool}] ${status}`.trimEnd();
438
+ }
439
+ case "error": {
440
+ return "[error] session error";
441
+ }
442
+ default:
443
+ // step_start / step_finish / reasoning — skipped for live logs.
444
+ return null;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Build the `opencode run` command.
450
+ *
451
+ * `--format json` emits NDJSON events we capture on stdout. `--agent plan` is
452
+ * OpenCode's read-oriented agent — a sensible default for review (it does not
453
+ * edit). `--dir <cwd>` matches the spawn cwd. The message (prompt) is the
454
+ * trailing positional arg. `--model` is `provider/model` and is omitted when
455
+ * empty so OpenCode uses the configured default.
456
+ */
457
+ function opencodeBuildArgv(prompt: string, model?: string, cwd?: string): string[] {
458
+ const useModel = !!model && model.trim().length > 0;
459
+ return [
460
+ "opencode",
461
+ "run",
462
+ "--format",
463
+ "json",
464
+ "--agent",
465
+ "plan",
466
+ ...(useModel ? ["--model", model] : []),
467
+ ...(cwd ? ["--dir", cwd] : []),
468
+ // Message (prompt) is the trailing positional arg.
469
+ prompt,
470
+ ];
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // The two descriptors + registry.
475
+ // ---------------------------------------------------------------------------
476
+
477
+ const CURSOR_ENGINE: MarkerEngine = {
478
+ id: "cursor",
479
+ name: "Cursor CLI",
480
+ binary: "agent",
481
+ author: "Cursor",
482
+ buildArgv: cursorBuildArgv,
483
+ extractText: cursorExtractText,
484
+ modelsArgv: ["models"],
485
+ parseModels: cursorParseModels,
486
+ formatLogEvent: cursorFormatLogEvent,
487
+ };
488
+
489
+ const OPENCODE_ENGINE: MarkerEngine = {
490
+ id: "opencode",
491
+ name: "OpenCode",
492
+ binary: "opencode",
493
+ author: "OpenCode",
494
+ buildArgv: opencodeBuildArgv,
495
+ extractText: opencodeExtractText,
496
+ modelsArgv: ["models"],
497
+ parseModels: opencodeParseModels,
498
+ formatLogEvent: opencodeFormatLogEvent,
499
+ };
500
+
501
+ export const MARKER_ENGINES: Record<"cursor" | "opencode", MarkerEngine> = {
502
+ cursor: CURSOR_ENGINE,
503
+ opencode: OPENCODE_ENGINE,
504
+ };
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Stream reduction — line-buffered NDJSON reducer, shared across engines.
508
+ // ---------------------------------------------------------------------------
509
+
510
+ export interface MarkerStreamReduction {
511
+ /** Canonical assistant/result text — the substrate for marker extraction. */
512
+ canonicalText: string;
513
+ /** Number of NDJSON records that parsed successfully. */
514
+ recordCount: number;
515
+ }
516
+
517
+ /**
518
+ * Reduce a complete NDJSON stdout buffer into canonical text by calling the
519
+ * engine's `extractText` on every parsed record.
520
+ *
521
+ * Critically line-buffered: only complete lines (terminated by `\n`) are parsed.
522
+ * A trailing partial line with no newline is treated as complete (the process
523
+ * has exited by the time this runs on the accumulated buffer), but mid-stream
524
+ * chunk boundaries never corrupt a record because the whole buffer is joined
525
+ * before splitting on newlines.
526
+ */
527
+ export function reduceMarkerStream(stdout: string, engine: MarkerEngine): MarkerStreamReduction {
528
+ let canonicalText = "";
529
+ let recordCount = 0;
530
+
531
+ if (!stdout) return { canonicalText, recordCount };
532
+
533
+ for (const rawLine of stdout.split("\n")) {
534
+ const line = rawLine.trim();
535
+ if (!line) continue;
536
+
537
+ let event: MarkerStreamEvent;
538
+ try {
539
+ event = JSON.parse(line) as MarkerStreamEvent;
540
+ } catch {
541
+ continue; // not a complete/valid NDJSON record — skip
542
+ }
543
+ recordCount++;
544
+
545
+ const text = engine.extractText(event);
546
+ if (text) canonicalText += text;
547
+ }
548
+
549
+ return { canonicalText, recordCount };
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Marker-block parsing — reduce → take the LAST complete marker block → parse →
554
+ // schema-validate. Returns null on any failure (caller fails the job).
555
+ // ---------------------------------------------------------------------------
556
+
557
+ /**
558
+ * Extract the content of the LAST complete marker block from canonical text.
559
+ * The open/close tags carry the job nonce, so bare `plannotator-review-json`
560
+ * mentions in the diff or the model's prose (which lack the nonce) are ignored
561
+ * rather than mistaken for delimiters.
562
+ */
563
+ export function extractLastMarkerBlock(text: string, openTag: string, closeTag: string): string | null {
564
+ let result: string | null = null;
565
+ let searchFrom = 0;
566
+
567
+ while (true) {
568
+ const open = text.indexOf(openTag, searchFrom);
569
+ if (open === -1) break;
570
+ const contentStart = open + openTag.length;
571
+ const close = text.indexOf(closeTag, contentStart);
572
+ // A dangling opener (no matching close) almost always means the model's
573
+ // final block was truncated mid-stream. Fail closed — return null — rather
574
+ // than silently falling back to an earlier (draft, or echoed prompt-example)
575
+ // block, which would mark a truncated review green with the wrong findings.
576
+ if (close === -1) return null;
577
+ result = text.slice(contentStart, close);
578
+ searchFrom = close + closeTag.length;
579
+ }
580
+
581
+ return result;
582
+ }
583
+
584
+ /**
585
+ * Parse a marker engine's NDJSON stdout into a validated review output.
586
+ *
587
+ * Pipeline: line-buffered NDJSON reduce → reconstruct canonical text → take the
588
+ * LAST complete marker block → JSON.parse → schema-validate. Returns null on
589
+ * ANY failure (missing marker, malformed JSON, schema mismatch) so the caller
590
+ * can fail the job.
591
+ */
592
+ export function parseMarkerStreamOutput(stdout: string, engine: MarkerEngine, nonce: string): MarkerReviewOutput | null {
593
+ if (!stdout || !stdout.trim()) return null;
594
+ if (!nonce) return null; // no expected nonce → cannot trust any block
595
+
596
+ const { canonicalText } = reduceMarkerStream(stdout, engine);
597
+ if (!canonicalText) return null;
598
+
599
+ const block = extractLastMarkerBlock(canonicalText, markerOpen(nonce), markerClose(nonce));
600
+ if (block === null) return null;
601
+
602
+ let parsed: unknown;
603
+ try {
604
+ parsed = JSON.parse(block.trim());
605
+ } catch {
606
+ return null;
607
+ }
608
+
609
+ return validateMarkerReviewOutput(parsed);
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Live log formatter — parse one NDJSON line, hand the event to the engine.
614
+ // ---------------------------------------------------------------------------
615
+
616
+ /**
617
+ * Format one NDJSON line for the LiveLogViewer using the engine's per-event
618
+ * formatter. Returns a human-readable string, or null if the line should be
619
+ * skipped (unparseable or not log-worthy).
620
+ */
621
+ export function formatMarkerLogEvent(line: string, engine: MarkerEngine): string | null {
622
+ let event: MarkerStreamEvent;
623
+ try {
624
+ event = JSON.parse(line) as MarkerStreamEvent;
625
+ } catch {
626
+ return null;
627
+ }
628
+ return engine.formatLogEvent(event);
629
+ }
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // Review prompt — investigation-first methodology (shared with Cursor/OpenCode
633
+ // today, byte-identical) split from the marker-block output contract so a custom
634
+ // review profile can replace the methodology while the contract — the only thing
635
+ // that makes marker output parseable — is ALWAYS appended.
636
+ // ---------------------------------------------------------------------------
637
+
638
+ export const MARKER_REVIEW_METHODOLOGY = `# Code Review
639
+
640
+ ## Your role
641
+ You are a senior engineer reviewing a code change. Find the bugs a maintainer
642
+ would fix before merging — logic errors, regressions, broken edge cases,
643
+ security holes with a real exploit path, data-loss risks. You are not a linter
644
+ or style checker. Optimize for findings that get resolved, not for volume: a
645
+ few real, well-evidenced bugs beat a long list of nits.
646
+
647
+ ## Method — investigate before you report
648
+ You have tools. Use them. Do not judge the diff in isolation:
649
+ - Read the full function and module around each change, not just the hunk.
650
+ - Trace call sites and data flow to see how the changed code is actually used.
651
+ - Read any repo guidance and honor it if present: AGENTS.md, CLAUDE.md,
652
+ REVIEW.md, and any rules files at the repo root and in the directories
653
+ you're reviewing. Treat these as authoritative for this repo and respect any
654
+ skip/ignore rules they define.
655
+ - Before reporting, check whether the issue is already handled elsewhere (a
656
+ guard, try/catch, fallback, type guarantee).
657
+ - Look at sibling code and tests: is the pattern you're flagging used safely
658
+ elsewhere? Is the failure path you fear already covered?
659
+
660
+ ## What to look for
661
+ - Correctness: logic errors, regressions, broken edge cases, off-by-one,
662
+ wrong results, build/type breakage.
663
+ - Robustness: unhandled errors on realistic paths, resource/process leaks,
664
+ races with observable consequences, state left inconsistent on failure.
665
+ - Security (only when the change introduces it): execution of
666
+ user/client-controlled input, trusting unvalidated external or model
667
+ output, weakened trust/read-only guarantees, newly exposed surfaces.
668
+ - Contracts & attribution: wrong file/line/path mapping, scope confusion, or a
669
+ change to one side of a contract (types, API shape, parallel
670
+ implementations) without the matching change on the other side.
671
+ - Clear, citable violations of the repo guidance files above.
672
+
673
+ ## Validate every finding
674
+ Confirm each candidate is real by tracing the actual code path. If you can't
675
+ show how it triggers, drop it — prefer silence over a false positive. The
676
+ confirmation you write becomes the \`reasoning\` field: what triggers it, what
677
+ breaks, and why it isn't already handled.
678
+
679
+ ## Severity — report what a maintainer would actually fix
680
+ important — should be fixed before merge: data loss, silent wrong results,
681
+ security with an exploit path, crashes/leaks, contract drift that breaks a
682
+ real consumer, fail-open where it must fail-closed.
683
+
684
+ nit — minor and non-blocking: a real but low-impact edge case, missing
685
+ handling on an unlikely path, a robustness gap with an easy workaround.
686
+
687
+ pre_existing — a genuine bug in surrounding code NOT introduced by this
688
+ change. Only flag when it directly affects the changed code path.
689
+
690
+ ## Do NOT report
691
+ - Formatting, naming, or style preferences (unless a repo guideline requires).
692
+ - "Consider adding tests" — unless the change adds non-trivial logic with no
693
+ test coverage at all.
694
+ - Hypothetical problems needing exotic conditions with no real code path.
695
+ - Speculative refactors or "this could be cleaner" outside the change's scope.
696
+
697
+ ## Hard constraints
698
+ - This is a read-only review. Do NOT modify files.
699
+ - Do NOT post any comments to GitHub or GitLab. Do NOT use gh pr comment, glab,
700
+ or any commenting tool.
701
+ - Never approve or block the change. Your only output is findings.`;
702
+
703
+ export function buildMarkerOutputContract(nonce: string): string {
704
+ return `## Output contract
705
+ Your only machine-readable output is a single marker-delimited JSON block.
706
+ Any natural-language commentary you write must come BEFORE the final marker
707
+ block. Emit the block exactly once, as the last thing in your response. The
708
+ opening and closing tags carry a session id (after the colon) — reproduce both
709
+ tags EXACTLY as shown, including that id, or your findings will be discarded:
710
+
711
+ ${markerOpen(nonce)}
712
+ {
713
+ "findings": [
714
+ {
715
+ "file": "packages/server/review.ts",
716
+ "line": 123,
717
+ "end_line": 123,
718
+ "severity": "important",
719
+ "description": "The issue...",
720
+ "reasoning": "Why this is a real issue..."
721
+ }
722
+ ],
723
+ "summary": {
724
+ "correctness": "Issues Found",
725
+ "explanation": "One important issue was found.",
726
+ "confidence": 0.85
727
+ }
728
+ }
729
+ ${markerClose(nonce)}
730
+
731
+ Schema:
732
+ - findings: array of objects, each with
733
+ - file: string or null — path as shown in the diff. Use null for a general,
734
+ review-level note that isn't about any one file.
735
+ - line: integer or null — start line, post-change numbering. Give a line for a
736
+ line-level issue; use null for a whole-file issue or a general note.
737
+ - end_line: integer or null — end line; equal to line for a single line, null
738
+ when line is null.
739
+ - severity: one of "important", "nit", "pre_existing"
740
+ - description: string (one paragraph) — state the IMPACT (what breaks, for
741
+ whom) and the TRIGGER (when it happens); suggest a minimal fix if obvious
742
+ - reasoning: string (how the issue was confirmed)
743
+ - summary: object with
744
+ - correctness: string ("Correct" or "Issues Found")
745
+ - explanation: string (one sentence)
746
+ - confidence: number between 0 and 1
747
+
748
+ Place each finding by how specific it is: give file AND line for a line-level
749
+ issue; give file with line null for a whole-file issue; set both file and line
750
+ null for a general, review-level note. Never invent a line you are unsure of —
751
+ drop to a file or general placement instead of guessing. Cite file/line from the
752
+ new (post-change) code. One finding per distinct bug — do not stack unrelated
753
+ issues. If no issues are found, return an empty "findings" array with a valid
754
+ summary.`;
755
+ }
756
+
757
+ /**
758
+ * Compose a marker engine's review prompt.
759
+ *
760
+ * A custom review profile REPLACES the methodology (its own instructions take
761
+ * over). But unlike Claude/Codex — which use a native schema flag — marker
762
+ * engines have NO schema flag, so the marker output contract is the ONLY thing
763
+ * that makes their output parseable. It is therefore ALWAYS appended, even for a
764
+ * custom profile. (This is why we do NOT call composeReviewPrompt directly: that
765
+ * helper REPLACES the whole system prompt for custom profiles, which would strip
766
+ * our contract.)
767
+ *
768
+ * The contract embeds `nonce` in its marker tags; the caller must generate it
769
+ * with makeMarkerNonce() and recover it with extractMarkerNonce() at parse time.
770
+ *
771
+ * <methodology OR custom profile instructions>
772
+ * <marker output contract (nonce-tagged)>
773
+ * ---
774
+ * <user message>
775
+ */
776
+ export function composeMarkerReviewPrompt(
777
+ profile: ResolvedReviewProfile | undefined,
778
+ userMessage: string,
779
+ nonce: string,
780
+ ): string {
781
+ const head = profileHasCustomSection(profile)
782
+ ? (profile as ResolvedReviewProfile).instructions.trim()
783
+ : MARKER_REVIEW_METHODOLOGY;
784
+ return head + "\n\n" + buildMarkerOutputContract(nonce) + "\n\n---\n\n" + userMessage;
785
+ }
786
+
787
+ // ---------------------------------------------------------------------------
788
+ // Command builder
789
+ // ---------------------------------------------------------------------------
790
+
791
+ export interface MarkerCommandResult {
792
+ command: string[];
793
+ }
794
+
795
+ /** Build the full argv for a review run with the given engine. */
796
+ export function buildMarkerCommand(
797
+ engine: MarkerEngine,
798
+ prompt: string,
799
+ model?: string,
800
+ cwd?: string,
801
+ ): MarkerCommandResult {
802
+ return { command: engine.buildArgv(prompt, model, cwd) };
803
+ }
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // Finding transform — marker findings → external annotations. IDENTICAL logic
807
+ // to transformClaudeFindings (classifyFindingPlacement; nothing dropped); only
808
+ // the author differs, supplied by the engine descriptor.
809
+ // ---------------------------------------------------------------------------
810
+
811
+ export type MarkerReviewAnnotationInput = ReviewAnnotationInput;
812
+
813
+ /** Transform marker findings into the external annotation format. */
814
+ export function transformMarkerFindings(
815
+ findings: MarkerFinding[],
816
+ source: string,
817
+ author: string,
818
+ cwd?: string,
819
+ pathTransform?: (path: string) => string,
820
+ ): MarkerReviewAnnotationInput[] {
821
+ // Routing (line / whole-file / general) is shared with Claude in
822
+ // review-findings.ts — nothing is dropped; only the author differs per engine.
823
+ return transformSeverityFindings(findings, source, author, cwd, pathTransform);
824
+ }