@plannotator/pi-extension 0.27.10 → 0.27.11

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,500 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/feedback-archive.ts
2
+ /**
3
+ * Feedback Archive — durable local storage of every submitted review.
4
+ *
5
+ * Plannotator's decision paths hand the user's feedback to the invoking agent
6
+ * and then forget it: code review persisted nothing at all, plan decisions
7
+ * only landed in `plans/` while the client-side planSave setting was on (and
8
+ * overwrote the previous decision for the same slug), and the annotate
9
+ * surfaces only kept the #678 record for single local files. This module is
10
+ * the one place all of those write a durable, analyzable record of what the
11
+ * user actually submitted.
12
+ *
13
+ * Layout (per project, mirroring the `history/` project convention):
14
+ *
15
+ * {DATA_DIR}/feedback/{project}/index.jsonl append-only, authoritative
16
+ * {DATA_DIR}/feedback/{project}/records/…​.md human-readable sidecar
17
+ *
18
+ * The JSONL line is self-contained: an analyzer never has to open a sidecar.
19
+ * The sidecar exists because everything else in the data dir is markdown and
20
+ * users grep it; it is written only for records that carry content (a bare
21
+ * approval or a dismissal is a decision-only line).
22
+ *
23
+ * SHARED INDEX, not a Plannotator-private store. Several tools that share this
24
+ * data dir append to the SAME `feedback/{project}/index.jsonl`, distinguished
25
+ * by the `client` field on every line rather than by separate files. Known
26
+ * writers: `plannotator` (this module) and `plannotator-tui`, the Rust
27
+ * terminal client; `herdr-annotate` is reserved for a possible future Lite
28
+ * writer. A record's meaning is the same whoever wrote it, so an analyzer
29
+ * reads one file, sorts by `ts`, and filters by `client` only when it actually
30
+ * cares who submitted. Consequences worth respecting when changing this file:
31
+ * the line shape is a cross-tool contract (fields are added, never
32
+ * repurposed), other clients suffix their id onto their sidecar filenames
33
+ * (`{stamp}-{surface}-{decision}-plannotator-tui.md`), and unknown fields must
34
+ * be ignored rather than rejected.
35
+ *
36
+ * Contract, shared with `persistAnnotateSubmission` (#678):
37
+ * - This module NEVER throws. Any failure is logged once and reported as
38
+ * `null`, so a full disk can never turn a reviewer's submit into a 500.
39
+ * - Callers append BEFORE deleting the reviewer's draft: a failed archive
40
+ * write leaves the draft behind as the recovery copy.
41
+ * - The data directory is resolved PER CALL (not captured at module load
42
+ * like storage.ts does), so a test can redirect PLANNOTATOR_DATA_DIR
43
+ * inside the test body — Bun runs every test file in one process, and a
44
+ * module-load capture cannot be redirected without import-order games.
45
+ *
46
+ * Runtime-agnostic: node:fs / node:path only, so Pi vendors it unmodified.
47
+ */
48
+
49
+ import { appendFileSync, mkdirSync, writeFileSync } from "fs";
50
+ import { join } from "path";
51
+ import { getPlannotatorDataDir } from "./data-dir.ts";
52
+ import { extractDirName, extractRepoName, sanitizeTag } from "./project.ts";
53
+
54
+ /** Schema version carried on every line. Bump only on a breaking shape change. */
55
+ export const FEEDBACK_RECORD_VERSION = 1;
56
+
57
+ /**
58
+ * Tool that authored the record, and the only thing separating writers in a
59
+ * shared index: every client appends its own lines to the same
60
+ * `feedback/{project}/index.jsonl` and stamps itself here. Known values today
61
+ * are `plannotator` (this module) and `plannotator-tui`; `herdr-annotate` is
62
+ * reserved. Readers must treat this as an open set, never an enum to validate
63
+ * against.
64
+ */
65
+ export const FEEDBACK_RECORD_CLIENT = "plannotator";
66
+
67
+ export type FeedbackSurface =
68
+ | "plan"
69
+ | "review"
70
+ | "annotate"
71
+ | "annotate-url"
72
+ | "annotate-app"
73
+ | "annotate-last"
74
+ | "annotate-folder";
75
+
76
+ export type FeedbackDecision =
77
+ | "approved"
78
+ | "approved-with-notes"
79
+ | "denied"
80
+ | "feedback"
81
+ | "lgtm"
82
+ | "dismissed";
83
+
84
+ /** Identity of the reviewed changeset. Deliberately NOT the patch bytes: the
85
+ * refs plus the snapshot id are enough to regenerate the diff from the repo,
86
+ * and guide history already showed what uncapped patch copies cost on disk. */
87
+ export interface FeedbackReviewTarget {
88
+ vcsType?: string;
89
+ diffType?: string;
90
+ base?: string;
91
+ gitRef?: string;
92
+ snapshotId?: string;
93
+ /**
94
+ * The review's working directory at submit time. Recorded as provenance,
95
+ * not as a durable handle: a PR review started with `--local` points at a
96
+ * per-PR pool checkout that is cleaned up when the session ends, so this
97
+ * path can be gone by the time anyone reads the record. `pr` plus `gitRef`
98
+ * are the identity that survives.
99
+ */
100
+ cwd?: string;
101
+ pr?: { provider: string; repo: string; number: number };
102
+ changedFiles?: number;
103
+ patchBytes?: number;
104
+ }
105
+
106
+ export interface FeedbackTarget {
107
+ /** Plan history slug (plan surface). */
108
+ slug?: string;
109
+ /** Plan history version this decision was made on. */
110
+ planVersion?: number;
111
+ /** Absolute path of `history/{project}/{slug}/NNN.md` — join the record to
112
+ * the exact plan text without duplicating it into the archive. */
113
+ planVersionFile?: string;
114
+ /**
115
+ * The annotate session's own target: the resolved file for a single-file
116
+ * session, and the session's FOLDER for a folder session (not the document
117
+ * that happened to be open when the reviewer submitted — a folder session
118
+ * submits one body of feedback for the whole session, and the per-document
119
+ * path is not part of it).
120
+ */
121
+ filePath?: string;
122
+ /**
123
+ * Annotated URL (URL sessions) or the live app's target URL, stored in full
124
+ * including its query string, because that is the page that was reviewed.
125
+ * A URL carrying a token or other secret in its query is therefore written
126
+ * to disk; the archive opt-out is the control for that.
127
+ */
128
+ url?: string;
129
+ /**
130
+ * Provenance for surfaces whose subject is an AGENT SESSION rather than a
131
+ * file or a diff: annotate-last and the other message-shaped surfaces, where
132
+ * "what was reviewed" is a transcript, not a path.
133
+ *
134
+ * Declared in v1 so the field name is reserved across every client sharing
135
+ * the index (plannotator-tui populates it); this module does not write it
136
+ * yet. Readers must tolerate its absence.
137
+ */
138
+ agent?: {
139
+ /** Agent host that produced the session, e.g. "claude-code" or "pi". */
140
+ host?: string;
141
+ /** Host-assigned session id. */
142
+ session?: string;
143
+ /** Path or id of the transcript the reviewed message came from. */
144
+ transcript?: string;
145
+ };
146
+ review?: FeedbackReviewTarget;
147
+ }
148
+
149
+ /** One submitted annotation, shallow-normalized. Unknown shapes never throw:
150
+ * anything unrecognized is simply absent from the record. */
151
+ export interface FeedbackAnnotationRecord {
152
+ id?: string;
153
+ type?: string;
154
+ text?: string;
155
+ originalText?: string;
156
+ file?: string;
157
+ lineStart?: number;
158
+ lineEnd?: number;
159
+ side?: string;
160
+ blockId?: string;
161
+ diffContext?: string;
162
+ severity?: string;
163
+ inReplyTo?: string;
164
+ /** External tool identifier ("eslint", "browser-agent", a review job).
165
+ * Absent means the human wrote it — that is the "my own comments" filter. */
166
+ source?: string;
167
+ author?: string;
168
+ images?: number;
169
+ }
170
+
171
+ export interface FeedbackRecord {
172
+ v: number;
173
+ ts: string;
174
+ client: string;
175
+ /**
176
+ * Version of the writing client, when it knows its own. Additive and
177
+ * optional: this module does not populate it (packages/shared has no
178
+ * runtime-agnostic version constant, and reading package.json from a
179
+ * vendored module would be a new filesystem dependency for cosmetic data),
180
+ * but the field is named in v1 so clients that DO know their version write
181
+ * it under one agreed key instead of inventing three.
182
+ */
183
+ clientVersion?: string;
184
+ project: string;
185
+ origin?: string;
186
+ surface: FeedbackSurface;
187
+ decision: FeedbackDecision;
188
+ target?: FeedbackTarget;
189
+ feedback?: string;
190
+ annotations?: FeedbackAnnotationRecord[];
191
+ counts: { annotations: number; external: number; images: number };
192
+ /** Sidecar path relative to the project directory. Absent on decision-only lines. */
193
+ recordFile?: string;
194
+ }
195
+
196
+ export interface FeedbackArchiveInput {
197
+ /** Project namespace, same convention as `history/{project}`. */
198
+ project: string;
199
+ origin?: string;
200
+ surface: FeedbackSurface;
201
+ decision: FeedbackDecision;
202
+ target?: FeedbackTarget;
203
+ /** Exported human-readable feedback (byte-identical to what the agent got). */
204
+ feedback?: unknown;
205
+ /** Raw annotations array from the submit body. */
206
+ annotations?: unknown;
207
+ /** Injected by tests to force the failure branch. */
208
+ now?: Date;
209
+ }
210
+
211
+ /**
212
+ * Derive the archive's project segment.
213
+ *
214
+ * `history/` keys by whatever `detectProjectName()` returned (already
215
+ * sanitizeTag'd) or the literal `_unknown`, and the archive matches it so both
216
+ * stores bucket the same session identically. Every other value is
217
+ * sanitizeTag'd, which is also what keeps a caller-derived name (the review
218
+ * server reads it off the repo path) from ever escaping the archive directory.
219
+ */
220
+ export function normalizeFeedbackProject(project: string | null | undefined): string {
221
+ if (!project) return "_unknown";
222
+ if (project === "_unknown") return project;
223
+ return sanitizeTag(project) ?? "_unknown";
224
+ }
225
+
226
+ /**
227
+ * Best-effort project name for a server that has a working directory but no
228
+ * detected project (the review server). Mirrors `detectProjectName`'s fallback
229
+ * chain without shelling out to git: the review cwd is already the repo root
230
+ * for every local VCS provider.
231
+ */
232
+ export function deriveFeedbackProject(cwd: string | undefined): string {
233
+ if (!cwd) return "_unknown";
234
+ return extractDirName(cwd) ?? extractRepoName(cwd) ?? "_unknown";
235
+ }
236
+
237
+ function feedbackProjectDir(project: string): string {
238
+ return join(getPlannotatorDataDir(), "feedback", normalizeFeedbackProject(project));
239
+ }
240
+
241
+ /** Filesystem-safe ISO stamp, same convention as `saveAnnotateSubmission`. */
242
+ function stamp(now: Date): string {
243
+ return now.toISOString().replace(/[:.]/g, "-");
244
+ }
245
+
246
+ function asString(value: unknown): string | undefined {
247
+ return typeof value === "string" && value.length > 0 ? value : undefined;
248
+ }
249
+
250
+ function asNumber(value: unknown): number | undefined {
251
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
252
+ }
253
+
254
+ /**
255
+ * Shallow-normalize one submitted annotation.
256
+ *
257
+ * Markdown annotations (plan/annotate) and code annotations (review) are
258
+ * different shapes; both are read leniently and the union of their known
259
+ * fields is recorded. Provenance (`source`, `author`) is always preserved:
260
+ * external, WebMCP, and agent-sourced annotations belong in the record —
261
+ * the submitted feedback text already embeds them — but must stay
262
+ * distinguishable from what the human wrote.
263
+ */
264
+ function normalizeAnnotation(raw: unknown): FeedbackAnnotationRecord {
265
+ if (typeof raw !== "object" || raw === null) return {};
266
+ const a = raw as Record<string, unknown>;
267
+ const record: FeedbackAnnotationRecord = {};
268
+ const id = asString(a.id);
269
+ if (id) record.id = id;
270
+ const type = asString(a.type);
271
+ if (type) record.type = type;
272
+ const text = asString(a.text);
273
+ if (text) record.text = text;
274
+ const originalText = asString(a.originalText) ?? asString(a.selectedText) ?? asString(a.tokenText);
275
+ if (originalText) record.originalText = originalText;
276
+ const file = asString(a.filePath) ?? asString(a.file);
277
+ if (file) record.file = file;
278
+ const lineStart = asNumber(a.lineStart);
279
+ if (lineStart !== undefined) record.lineStart = lineStart;
280
+ const lineEnd = asNumber(a.lineEnd);
281
+ if (lineEnd !== undefined) record.lineEnd = lineEnd;
282
+ const side = asString(a.side);
283
+ if (side) record.side = side;
284
+ const blockId = asString(a.blockId);
285
+ if (blockId) record.blockId = blockId;
286
+ const diffContext = asString(a.diffContext);
287
+ if (diffContext) record.diffContext = diffContext;
288
+ const severity = asString(a.severity);
289
+ if (severity) record.severity = severity;
290
+ const inReplyTo = asString(a.inReplyTo);
291
+ if (inReplyTo) record.inReplyTo = inReplyTo;
292
+ const source = asString(a.source);
293
+ if (source) record.source = source;
294
+ const author = asString(a.author);
295
+ if (author) record.author = author;
296
+ if (Array.isArray(a.images) && a.images.length > 0) record.images = a.images.length;
297
+ return record;
298
+ }
299
+
300
+ const SURFACE_TITLES: Record<FeedbackSurface, string> = {
301
+ plan: "Plan review feedback",
302
+ review: "Code review feedback",
303
+ annotate: "Annotate feedback",
304
+ "annotate-url": "Annotate feedback (URL)",
305
+ "annotate-app": "Annotate feedback (live app)",
306
+ "annotate-last": "Annotate feedback (agent message)",
307
+ "annotate-folder": "Annotate feedback (folder)",
308
+ };
309
+
310
+ /**
311
+ * Render the human-readable sidecar: a short metadata header plus the exact
312
+ * feedback text the agent received.
313
+ */
314
+ export function renderFeedbackRecordMarkdown(record: FeedbackRecord): string {
315
+ const lines: string[] = [`# ${SURFACE_TITLES[record.surface] ?? "Feedback"}`, ""];
316
+ lines.push(`- Submitted: ${record.ts}`);
317
+ lines.push(`- Surface: ${record.surface}`);
318
+ lines.push(`- Decision: ${record.decision}`);
319
+ lines.push(`- Project: ${record.project}`);
320
+ if (record.origin) lines.push(`- Origin: ${record.origin}`);
321
+ const target = record.target;
322
+ if (target?.filePath) lines.push(`- File: ${target.filePath}`);
323
+ if (target?.url) lines.push(`- URL: ${target.url}`);
324
+ if (target?.slug) {
325
+ lines.push(
326
+ `- Plan: ${target.slug}${target.planVersion ? ` (version ${target.planVersion})` : ""}`,
327
+ );
328
+ }
329
+ if (target?.planVersionFile) lines.push(`- Plan version file: ${target.planVersionFile}`);
330
+ const review = target?.review;
331
+ if (review) {
332
+ const bits = [review.diffType, review.base ? `base ${review.base}` : null, review.gitRef ? `ref ${review.gitRef}` : null]
333
+ .filter(Boolean)
334
+ .join(", ");
335
+ if (bits) lines.push(`- Diff: ${bits}`);
336
+ if (review.cwd) lines.push(`- Repository: ${review.cwd}`);
337
+ if (review.pr) lines.push(`- Pull request: ${review.pr.provider} ${review.pr.repo}#${review.pr.number}`);
338
+ }
339
+ lines.push(
340
+ `- Annotations: ${record.counts.annotations}${record.counts.external > 0 ? ` (${record.counts.external} external)` : ""}`,
341
+ );
342
+ lines.push("", "---", "");
343
+ lines.push(record.feedback && record.feedback.trim() ? record.feedback : "_No feedback text submitted._");
344
+ lines.push("");
345
+ return lines.join("\n");
346
+ }
347
+
348
+ /**
349
+ * Append one submitted-feedback record to the archive.
350
+ *
351
+ * Returns the path of the index file that was appended to, or `null` when
352
+ * nothing durable was written (the caller then keeps the reviewer's draft).
353
+ * Never throws.
354
+ *
355
+ * The sidecar is written before the index line even though the index is the
356
+ * authoritative store: it is created with the exclusive `wx` flag (retrying
357
+ * with a collision counter), so a record file can never be overwritten and an
358
+ * index line can never name a file that does not exist. An orphan sidecar
359
+ * after a failed append is harmless; a dangling reference would not be.
360
+ */
361
+ export function appendFeedbackRecord(input: FeedbackArchiveInput): string | null {
362
+ try {
363
+ const now = input.now ?? new Date();
364
+ const project = normalizeFeedbackProject(input.project);
365
+ const feedback = typeof input.feedback === "string" ? input.feedback : "";
366
+ const rawAnnotations = Array.isArray(input.annotations) ? input.annotations : [];
367
+ const annotations = rawAnnotations.map(normalizeAnnotation);
368
+ const counts = {
369
+ annotations: annotations.length,
370
+ external: annotations.filter((a) => a.source !== undefined).length,
371
+ images: annotations.reduce((sum, a) => sum + (a.images ?? 0), 0),
372
+ };
373
+ const hasContent = feedback.trim().length > 0 || annotations.length > 0;
374
+
375
+ const record: FeedbackRecord = {
376
+ v: FEEDBACK_RECORD_VERSION,
377
+ ts: now.toISOString(),
378
+ client: FEEDBACK_RECORD_CLIENT,
379
+ project,
380
+ ...(input.origin ? { origin: input.origin } : {}),
381
+ surface: input.surface,
382
+ decision: input.decision,
383
+ ...(input.target ? { target: input.target } : {}),
384
+ ...(feedback ? { feedback } : {}),
385
+ ...(annotations.length > 0 ? { annotations } : {}),
386
+ counts,
387
+ };
388
+
389
+ const projectDir = feedbackProjectDir(project);
390
+ mkdirSync(projectDir, { recursive: true });
391
+
392
+ if (hasContent) {
393
+ const recordsDir = join(projectDir, "records");
394
+ mkdirSync(recordsDir, { recursive: true });
395
+ // {stamp}-{surface}-{decision}[-N].md. Other clients writing into this
396
+ // shared archive suffix their own id (plannotator-tui writes
397
+ // `{stamp}-{surface}-{decision}-plannotator-tui.md`), which is why the
398
+ // records directory holds more shapes than this line produces and why
399
+ // nothing may parse a sidecar name: `recordFile` is the only handle, and
400
+ // any value it carries is valid.
401
+ const base = `${stamp(now)}-${input.surface}-${input.decision}`;
402
+ let name = `${base}.md`;
403
+ const body = renderFeedbackRecordMarkdown(record);
404
+ for (let n = 2; ; n++) {
405
+ try {
406
+ writeFileSync(join(recordsDir, name), body, { encoding: "utf-8", flag: "wx" });
407
+ break;
408
+ } catch (err) {
409
+ if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err;
410
+ // Exhausting the counter is not an ordinary collision: the stamp is
411
+ // per-millisecond, so 100 taken names in one millisecond means a
412
+ // stopped clock or a runaway writer. Say so, or the outer catch logs
413
+ // a bare EEXIST that reads like a transient disk problem.
414
+ if (n > 100) {
415
+ throw new Error(
416
+ `sidecar name collision exceeded 100 attempts for ${base}.md (clock stuck or runaway writer)`,
417
+ );
418
+ }
419
+ name = `${base}-${n}.md`;
420
+ }
421
+ }
422
+ record.recordFile = `records/${name}`;
423
+ // The sidecar body names the record's own metadata, not its filename, so
424
+ // no rewrite is needed once the final name is known.
425
+ }
426
+
427
+ const indexPath = join(projectDir, "index.jsonl");
428
+ // JSON.stringify can never emit a raw newline, so one record is always one
429
+ // line, and the whole line is handed to a single append-mode write.
430
+ //
431
+ // That is a practical guarantee, not a formal one, and the honest model is
432
+ // worth stating: appendFileSync itself loops internally (fs writes until
433
+ // the buffer is drained), so "one syscall" is wrong even locally. What
434
+ // holds in practice is that an O_APPEND write of a line-sized buffer
435
+ // completes without interleaving on a local filesystem. NFS and SMB do not
436
+ // promise even that, and a genuine interleave damages BOTH records that
437
+ // raced, not just the later one. The backstop is the reader:
438
+ // parseFeedbackIndex skips unparsable lines, so the blast radius is bounded
439
+ // at those records and every other line in the file stays readable. Several
440
+ // clients share this index, which is exactly when the caveat matters.
441
+ appendFileSync(indexPath, `${JSON.stringify(record)}\n`, "utf-8");
442
+ return indexPath;
443
+ } catch (error) {
444
+ console.error(
445
+ `[plannotator] warning: could not archive submitted feedback (${error instanceof Error ? error.message : String(error)}); keeping the annotation draft as the recovery copy`,
446
+ );
447
+ return null;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Read a project's archive, skipping unparsable lines.
453
+ *
454
+ * The read path in v1 is "the files on disk" (jq/grep); this helper exists so
455
+ * the servers' own tests and any future in-process reader agree on the
456
+ * torn-line tolerance the append contract promises. Never throws.
457
+ *
458
+ * Structural gate only: a line counts as a record when it parses and carries a
459
+ * numeric `v`. It deliberately does NOT filter by version or by `client`, so a
460
+ * newer writer's lines are still returned. An analyzer that depends on v1
461
+ * SEMANTICS should filter `v <= 1` itself; fields are only ever added, never
462
+ * repurposed, so a v2 would mean a real shape change rather than new keys.
463
+ */
464
+ export function parseFeedbackIndex(contents: string): FeedbackRecord[] {
465
+ const records: FeedbackRecord[] = [];
466
+ for (const line of contents.split("\n")) {
467
+ const trimmed = line.trim();
468
+ if (!trimmed) continue;
469
+ try {
470
+ const parsed = JSON.parse(trimmed) as FeedbackRecord;
471
+ if (parsed && typeof parsed === "object" && typeof parsed.v === "number") records.push(parsed);
472
+ } catch {
473
+ // Torn last line from a concurrent append — skip it, keep the rest.
474
+ }
475
+ }
476
+ return records;
477
+ }
478
+
479
+ /**
480
+ * Count the files a patch touches, for the record's `changedFiles` metadata.
481
+ *
482
+ * Deliberately not `extractChangedFiles` (code-nav): that one UNIONS the a/
483
+ * and b/ sides because it exists to resolve any path a reader might mention,
484
+ * so a rename counts twice and the record would overstate the review's size.
485
+ * The `diff --git` header always names a real path on both sides (deletions
486
+ * do not put /dev/null there), so the b side alone is one entry per file.
487
+ */
488
+ export function countChangedFiles(patch: string | null | undefined): number {
489
+ if (!patch) return 0;
490
+ const files = new Set<string>();
491
+ const re = /^diff --git a\/(.+?) b\/(.+)$/gm;
492
+ let match: RegExpExecArray | null;
493
+ while ((match = re.exec(patch)) !== null) files.add(match[2]);
494
+ return files.size;
495
+ }
496
+
497
+ /** Absolute path of a project's archive index (for callers that report it). */
498
+ export function feedbackIndexPath(project: string): string {
499
+ return join(feedbackProjectDir(project), "index.jsonl");
500
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.27.10",
3
+ "version": "0.27.11",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",
@@ -606,6 +606,7 @@ async function createCodeReviewBrowserSession(
606
606
  gitRef,
607
607
  error: diffError,
608
608
  origin: "pi",
609
+ project: detectProjectName(),
609
610
  diffType,
610
611
  gitContext: gitCtx,
611
612
  initialBase,