@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.
- package/generated/ai/providers/opencode-sdk.ts +70 -46
- package/generated/ai/types.ts +1 -1
- package/generated/config.ts +29 -0
- package/generated/feedback-archive.ts +500 -0
- package/package.json +1 -1
- package/plannotator-browser.ts +1 -0
- package/plannotator.html +132 -132
- package/review-editor.html +2 -2
- package/server/ai-runtime.ts +12 -5
- package/server/feedback-archive.test.ts +357 -0
- package/server/serverAnnotate.ts +71 -12
- package/server/serverPlan.ts +41 -1
- package/server/serverReview.ts +104 -5
|
@@ -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.
|
|
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",
|