@bugabinga/pi-ext-diff-review 0.1.0

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/PLAN.md ADDED
@@ -0,0 +1,770 @@
1
+ # diff-review plan
2
+
3
+ Interactive browser review workflow for Pi using Pierre.
4
+
5
+ ## Goal
6
+
7
+ Add `/diff-review`: collect a git diff, open a tokenized localhost browser UI powered by `@pierre/diffs` + `@pierre/trees`, let user annotate exact changed lines, then persist structured findings and send a user message back to Pi so the agent proposes a fix plan before editing.
8
+
9
+ ## Verdict-driven constraints
10
+
11
+ These are blockers, not polish:
12
+
13
+ - browser bundle lifecycle must be enforced before command starts
14
+ - Pierre APIs must match installed published typings, not repo assumptions
15
+ - browser opener must be explicit + safe with manual fallback
16
+ - command must never wait forever: timeout, heartbeat, abort, cancel command, shared cleanup
17
+ - static server must serve complete production output dir, not only `app.js`
18
+ - browser smoke test must validate production bundle in real browser before v1 is considered done
19
+
20
+ ## Workspace conventions from `AGENTS.md`
21
+
22
+ - New extension path is `agent/extensions/diff-review/`.
23
+ - Required first files:
24
+ 1. `agent/extensions/diff-review/package.json`
25
+ 2. `agent/extensions/diff-review/index.ts`
26
+ 3. run `bun install` after structural/package changes
27
+ - `package.json` must follow workspace template: `@bugabinga/pi-ext-diff-review`, `private`, `type: "module"`, `main: "index.ts"`.
28
+ - Add only deps actually imported by this extension.
29
+ - Pi packages (`@earendil-works/*`) go in `peerDependencies`.
30
+ - Do not import from any `@bugabinga/pi-ext-*` extension; copy local patterns instead.
31
+ - TS source imports local files with `.js` specifiers, matching existing extensions.
32
+ - Use existing extension patterns:
33
+ - constants in `constants.ts` (`EXTENSION_ID`, command names, status ids)
34
+ - types in `types.ts`
35
+ - session helpers in `session-state.ts`, following `q/session-state.ts`
36
+ - `index.ts` stays orchestration-only where possible
37
+
38
+ ## Verified research
39
+
40
+ ### Pi extension APIs
41
+
42
+ - Extension entrypoint: `agent/extensions/diff-review/index.ts` exporting `default function (pi: ExtensionAPI)`.
43
+ - Slash command via `pi.registerCommand("diff-review", { handler })`.
44
+ - Command handler should:
45
+ - require `ctx.hasUI`
46
+ - `await ctx.waitForIdle()` before starting
47
+ - block until browser submit/cancel/timeout/abort
48
+ - call `pi.sendUserMessage([{ type: "text", text: markdown }])` after submit
49
+ - Persist branch-local state via custom session entries:
50
+ - use `pi.appendEntry(customType, data)` when entry id is not needed
51
+ - use local helper with `ExtensionContext["sessionManager"]` cast when entry id is needed, matching `q/session-state.ts`
52
+ - Restore state on `session_start` by scanning `ctx.sessionManager.getBranch()` and selecting entries where:
53
+ - `entry.type === "custom"`
54
+ - `entry.customType === "diff-review"`
55
+ - Clean up active localhost server on `session_shutdown`.
56
+ - No verified generic Pi URL-open API found. Implement platform opener fallback in extension.
57
+
58
+ ### Verified `@pierre/diffs@1.1.22` typings
59
+
60
+ Installed package inspected from npm tarball under `~/.pi/research/extracts/pierre-npm/diffs`.
61
+
62
+ Relevant exact API:
63
+
64
+ ```ts
65
+ import {
66
+ FileDiff,
67
+ parsePatchFiles,
68
+ type DiffLineAnnotation,
69
+ type FileDiffMetadata,
70
+ type ParsedPatch,
71
+ type SelectedLineRange,
72
+ } from "@pierre/diffs";
73
+
74
+ const patches: ParsedPatch[] = parsePatchFiles(diffText, "diff-review", true);
75
+
76
+ const instance = new FileDiff<{ findingId: string }>({
77
+ theme: { dark: "pierre-dark", light: "pierre-light" },
78
+ diffStyle: "split",
79
+ overflow: "scroll",
80
+ enableLineSelection: true,
81
+ enableGutterUtility: true,
82
+ lineHoverHighlight: "both",
83
+ onLineSelected(range: SelectedLineRange | null) {},
84
+ onGutterUtilityClick(range: SelectedLineRange) {},
85
+ renderAnnotation(annotation: DiffLineAnnotation<{ findingId: string }>) {
86
+ return document.createElement("div");
87
+ },
88
+ });
89
+
90
+ const ok: boolean = instance.render({
91
+ fileDiff,
92
+ containerWrapper,
93
+ lineAnnotations,
94
+ });
95
+
96
+ instance.setLineAnnotations(lineAnnotations);
97
+ instance.setSelectedLines({ start, end, side: "additions", endSide: "additions" });
98
+ instance.rerender();
99
+ instance.cleanUp();
100
+ ```
101
+
102
+ `FileDiff.render(...)` accepts:
103
+
104
+ ```ts
105
+ {
106
+ fileDiff?: FileDiffMetadata;
107
+ oldFile?: FileContents;
108
+ newFile?: FileContents;
109
+ forceRender?: boolean;
110
+ preventEmit?: boolean;
111
+ fileContainer?: HTMLElement;
112
+ containerWrapper?: HTMLElement;
113
+ lineAnnotations?: DiffLineAnnotation<T>[];
114
+ renderRange?: RenderRange;
115
+ }
116
+ ```
117
+
118
+ `SelectedLineRange` is:
119
+
120
+ ```ts
121
+ {
122
+ start: number;
123
+ side?: "deletions" | "additions";
124
+ end: number;
125
+ endSide?: "deletions" | "additions";
126
+ }
127
+ ```
128
+
129
+ `DiffLineAnnotation<T>` is:
130
+
131
+ ```ts
132
+ {
133
+ side: "deletions" | "additions";
134
+ lineNumber: number;
135
+ metadata: T;
136
+ }
137
+ ```
138
+
139
+ Important asset note:
140
+
141
+ - `@pierre/diffs` ships `dist/style.js`; component code imports CSS through wrapper utilities/web component registration.
142
+ - Worker assets exist under `dist/worker/*`, but v1 will not instantiate `WorkerPoolManager`; no worker route needed unless later enabled.
143
+
144
+ ### Verified `@pierre/trees@1.0.0-beta.4` typings
145
+
146
+ Installed package inspected from npm tarball under `~/.pi/research/extracts/pierre-npm/trees`.
147
+
148
+ Relevant exact API:
149
+
150
+ ```ts
151
+ import { FileTree, type GitStatusEntry } from "@pierre/trees";
152
+
153
+ const tree = new FileTree({
154
+ paths,
155
+ search: true,
156
+ initialExpansion: "open",
157
+ initialSelectedPaths: [],
158
+ gitStatus,
159
+ onSelectionChange(selectedPaths: readonly string[]) {},
160
+ });
161
+
162
+ tree.render({ fileTreeContainer });
163
+ // or tree.render({ containerWrapper });
164
+
165
+ tree.getSelectedPaths();
166
+ tree.getFocusedPath();
167
+ tree.focusPath(path);
168
+ tree.scrollToPath(path);
169
+ tree.setGitStatus(gitStatus);
170
+ tree.cleanUp();
171
+ ```
172
+
173
+ `FileTree` render props are exactly:
174
+
175
+ ```ts
176
+ {
177
+ containerWrapper?: HTMLElement;
178
+ fileTreeContainer?: HTMLElement;
179
+ }
180
+ ```
181
+
182
+ ## Scope
183
+
184
+ ### Command
185
+
186
+ ```text
187
+ /diff-review [--base origin/main] [--staged] [--files a.ts,b.ts]
188
+ /diff-review-cancel
189
+ ```
190
+
191
+ Behavior:
192
+
193
+ - interactive-only
194
+ - waits for browser submit/cancel/timeout
195
+ - on submit:
196
+ 1. append `diff-review` custom entry
197
+ 2. shared cleanup
198
+ 3. send markdown review to Pi as user message
199
+ - on browser/user cancel: shared cleanup, no session entry, no user message
200
+ - `/diff-review-cancel`: abort active review server and unblock command
201
+ - starting a second `/diff-review` while one is active: notify error with active URL
202
+
203
+ Flag semantics:
204
+
205
+ - default: unstaged working tree diff (`git diff`), untracked files ignored in v1
206
+ - `--staged`: staged diff (`git diff --cached`)
207
+ - `--base <ref>`: branch/base comparison (`git diff <ref>...HEAD`)
208
+ - `--staged` and `--base` are mutually exclusive
209
+ - `--files a.ts,b.ts`: path filter, relative paths only, no absolute paths, no `..`
210
+
211
+ ### Git commands
212
+
213
+ Run with `execFile`/`pi.exec` args, never shell interpolation.
214
+
215
+ ```ts
216
+ // repo check/root
217
+ git rev-parse --show-toplevel
218
+
219
+ // default
220
+ git diff --no-ext-diff --find-renames --unified=3 -- [files...]
221
+
222
+ // staged
223
+ git diff --cached --no-ext-diff --find-renames --unified=3 -- [files...]
224
+
225
+ // base
226
+ git diff --no-ext-diff --find-renames --unified=3 <base>...HEAD -- [files...]
227
+ ```
228
+
229
+ Empty diff → notify `No diff to review` and return.
230
+
231
+ Binary-only/unparseable files:
232
+
233
+ - keep raw diff in payload
234
+ - mark file as skipped if no hunks can be rendered
235
+ - do not allow line findings for skipped files
236
+
237
+ ## Browser bundle lifecycle
238
+
239
+ ### Chosen policy
240
+
241
+ Commit generated production browser assets and also guard against stale/missing builds at runtime.
242
+
243
+ Implementation layout:
244
+
245
+ ```text
246
+ agent/extensions/diff-review/
247
+ ├── package.json
248
+ ├── index.ts # register commands/events; orchestration only
249
+ ├── constants.ts # EXTENSION_ID, command/status ids, defaults
250
+ ├── types.ts # shared node/browser serializable types
251
+ ├── args.ts # slash-arg parser
252
+ ├── git.ts # git diff collection + DiffIndex
253
+ ├── server.ts # localhost server + token auth + lifecycle
254
+ ├── session-state.ts # append/restore helpers, q-style
255
+ ├── format.ts # markdown formatting
256
+ ├── open-browser.ts # safe opener fallback
257
+ ├── scripts/
258
+ │ ├── build-browser.ts
259
+ │ └── smoke-browser.ts
260
+ ├── browser/
261
+ │ ├── index.html
262
+ │ ├── index.ts
263
+ │ └── style.css
264
+ └── static/
265
+ ├── index.html
266
+ ├── app.js
267
+ ├── app.css? # if Bun emits/copies one
268
+ ├── assets/... # any emitted assets
269
+ └── manifest.json # generated by build script
270
+ ```
271
+
272
+ Pattern rules:
273
+
274
+ - Node files import Pi types from `@earendil-works/pi-coding-agent`.
275
+ - Browser files import Pierre deps only; keep Pi SDK out of browser bundle.
276
+ - Local imports use `.js` specifiers, e.g. `import { EXTENSION_ID } from "./constants.js"`.
277
+ - No imports from sibling extensions.
278
+
279
+ `static/` is generated but committed for normal Pi runtime. Server serves all of `static/`, not individual hard-coded assets.
280
+
281
+ ### Build script requirements
282
+
283
+ Use a small `scripts/build-browser.ts`, not only raw `bun build`, because it must:
284
+
285
+ 1. copy `browser/index.html` → `static/index.html`
286
+ 2. bundle `browser/index.ts` → `static/app.js`
287
+ 3. copy/emit any CSS/assets Bun produces
288
+ 4. write `static/manifest.json` containing:
289
+ - source file mtimes + sha256 for `browser/**`
290
+ - package versions for `@pierre/diffs` and `@pierre/trees`
291
+ - build timestamp
292
+ - Bun version
293
+
294
+ Runtime `ensureBrowserBundle()`:
295
+
296
+ - if `static/index.html`, `static/app.js`, or manifest missing:
297
+ - try auto-build if `bun` is available and extension dir is writable
298
+ - otherwise fail fast with exact command: `cd agent/extensions/diff-review && bun install && bun run build`
299
+ - if manifest source hashes/mtimes do not match:
300
+ - try auto-build in dev workspace
301
+ - otherwise fail fast with same command
302
+ - if auto-build fails: show stderr + command, do not start server
303
+
304
+ ### Package scaffold
305
+
306
+ Start from `AGENTS.md` template, then add only imported deps:
307
+
308
+ ```json
309
+ {
310
+ "name": "@bugabinga/pi-ext-diff-review",
311
+ "version": "0.0.0",
312
+ "private": true,
313
+ "type": "module",
314
+ "main": "index.ts",
315
+ "scripts": {
316
+ "build": "bun ./scripts/build-browser.ts",
317
+ "smoke:browser": "bun ./scripts/smoke-browser.ts"
318
+ },
319
+ "dependencies": {
320
+ "@pierre/diffs": "^1.1.22",
321
+ "@pierre/trees": "^1.0.0-beta.4"
322
+ },
323
+ "peerDependencies": {
324
+ "@earendil-works/pi-coding-agent": "*"
325
+ }
326
+ }
327
+ ```
328
+
329
+ Notes:
330
+
331
+ - `@pierre/diffs` and `@pierre/trees` are `dependencies` because `browser/index.ts` imports them.
332
+ - Do not add `@earendil-works/pi-tui` unless Node-side rendering/widget code imports it.
333
+ - Do not add Playwright unless `scripts/smoke-browser.ts` imports it; if used, add as `devDependencies` only.
334
+ - After creating/changing this package file or build layout, run `bun install` from workspace/extension as required by `AGENTS.md`.
335
+
336
+ Run after scaffold/deps/asset changes:
337
+
338
+ ```bash
339
+ cd agent/extensions/diff-review
340
+ bun install
341
+ bun run build
342
+ bun run smoke:browser
343
+ ```
344
+
345
+ ## Browser launch strategy
346
+
347
+ No Pi URL-open API is verified, so use safe platform fallback.
348
+
349
+ `openBrowser(url)`:
350
+
351
+ - Termux: `termux-open-url <url>` if available
352
+ - macOS: `open <url>`
353
+ - Windows: `cmd.exe /c start "" <url>`
354
+ - Linux/BSD: `xdg-open <url>`
355
+ - WSL fallback if needed: `wslview <url>`
356
+
357
+ Rules:
358
+
359
+ - use `spawn`/`execFile`, no shell except Windows `cmd.exe /c start`
360
+ - opener failure is non-fatal
361
+ - always show manual URL via `ctx.ui.notify` and status/widget
362
+ - optionally copy URL if a clipboard command exists (`wl-copy`, `xclip`, `pbcopy`, `clip.exe`, `termux-clipboard-set`), but do not depend on clipboard
363
+
364
+ UI status while active:
365
+
366
+ ```text
367
+ diff-review: open http://127.0.0.1:<port>/?token=...
368
+ ```
369
+
370
+ ## Abort / timeout / heartbeat
371
+
372
+ All command exits go through one `finish(result)` path guarded by an idempotent state flag.
373
+
374
+ Active review state:
375
+
376
+ ```ts
377
+ type ActiveReview = {
378
+ id: string;
379
+ url: string;
380
+ server: Server;
381
+ controller: AbortController;
382
+ heartbeatTimer: NodeJS.Timeout;
383
+ timeoutTimer: NodeJS.Timeout;
384
+ lastHeartbeatAt: number;
385
+ resolve(result: BrowserResult): void;
386
+ };
387
+ ```
388
+
389
+ Timeouts:
390
+
391
+ - hard session timeout default: 30 min
392
+ - heartbeat interval from browser: 5s
393
+ - heartbeat considered lost after 30s
394
+ - heartbeat loss only warns first; hard timeout or explicit cancel ends command
395
+ - `/diff-review-cancel` ends immediately
396
+ - `session_shutdown` aborts active review immediately
397
+
398
+ Browser routes:
399
+
400
+ ```text
401
+ POST /api/heartbeat updates lastHeartbeatAt
402
+ POST /api/cancel explicit browser cancel
403
+ ```
404
+
405
+ If browser tab closes, heartbeat warning tells user to reopen URL or run `/diff-review-cancel`; hard timeout prevents indefinite hang.
406
+
407
+ ## Local server protocol
408
+
409
+ Bind only to localhost:
410
+
411
+ ```text
412
+ 127.0.0.1:0
413
+ ```
414
+
415
+ Token:
416
+
417
+ - `crypto.randomBytes(32).toString("base64url")`
418
+ - browser URL: `http://127.0.0.1:<port>/?token=<token>`
419
+ - all `/api/*` requests require `Authorization: Bearer <token>`
420
+ - static files require either token query on `/` or a same-origin session cookie set by `/`; API still uses bearer token
421
+
422
+ Routes:
423
+
424
+ ```text
425
+ GET / static/index.html; requires token query
426
+ GET /static/* files from static build dir
427
+ GET /api/review JSON review payload
428
+ POST /api/heartbeat heartbeat
429
+ POST /api/finding/validate validate selection + preview anchor
430
+ POST /api/submit submit notes/findings
431
+ POST /api/cancel cancel command
432
+ ```
433
+
434
+ `GET /api/review` response:
435
+
436
+ ```ts
437
+ type ReviewPayload = {
438
+ round: number;
439
+ source: ReviewSource;
440
+ diffText: string;
441
+ files: Array<{
442
+ path: string;
443
+ prevPath?: string;
444
+ status: "added" | "modified" | "deleted" | "renamed" | "binary";
445
+ additions: number;
446
+ deletions: number;
447
+ }>;
448
+ carriedFindings: Finding[];
449
+ };
450
+ ```
451
+
452
+ Command awaits one of:
453
+
454
+ ```ts
455
+ type BrowserResult =
456
+ | { type: "submit"; notes?: string; findings: FindingDraft[] }
457
+ | { type: "cancel"; reason: "browser" | "command" | "shutdown" | "timeout" };
458
+ ```
459
+
460
+ Cleanup:
461
+
462
+ - close server
463
+ - abort controller
464
+ - clear timers
465
+ - clear status/widget
466
+ - clean active review global
467
+
468
+ ## Browser UI
469
+
470
+ Layout:
471
+
472
+ - left sidebar: `@pierre/trees` changed-file tree
473
+ - main panel: one `@pierre/diffs` `FileDiff` per changed file
474
+ - right drawer: active selection + annotation form
475
+ - footer/header: submit/cancel, round number, source summary
476
+
477
+ Review drawer fields:
478
+
479
+ - category: `bug | style | perf | question`
480
+ - severity: `high | medium | low`
481
+ - comment: required
482
+ - notes: optional per-finding note or global notes textarea
483
+
484
+ Selection rules:
485
+
486
+ - use `onLineSelected(range)` and `onGutterUtilityClick(range)`
487
+ - accept only `range.side`/`range.endSide` in `additions | deletions`
488
+ - if mixed sides, reject for v1 with UI hint
489
+ - `startLine = Math.min(range.start, range.end)`
490
+ - `endLine = Math.max(range.start, range.end)`
491
+ - render saved findings as `DiffLineAnnotation<{ findingId, severity, category, comment }>`
492
+
493
+ Browser lifecycle:
494
+
495
+ - heartbeat starts after successful `/api/review`
496
+ - `beforeunload` tries best-effort `navigator.sendBeacon("/api/cancel")` only if no submit happened; command does not rely on this
497
+ - explicit Cancel button posts `/api/cancel`
498
+ - Submit disables form, posts `/api/submit`, then shows `Submitted — return to Pi`
499
+
500
+ Implementation sketch with verified APIs:
501
+
502
+ ```ts
503
+ import { FileDiff, parsePatchFiles, type SelectedLineRange } from "@pierre/diffs";
504
+ import { FileTree } from "@pierre/trees";
505
+
506
+ const review = await apiGetReview();
507
+ const patches = parsePatchFiles(review.diffText, `diff-review-${review.round}`, true);
508
+
509
+ const tree = new FileTree({
510
+ paths: review.files.map((f) => f.path),
511
+ search: true,
512
+ initialExpansion: "open",
513
+ gitStatus: toGitStatus(review.files),
514
+ onSelectionChange(paths) {
515
+ const path = paths[0];
516
+ if (path) scrollToDiff(path);
517
+ },
518
+ });
519
+ tree.render({ fileTreeContainer: document.getElementById("tree")! });
520
+
521
+ for (const patch of patches) {
522
+ for (const fileDiff of patch.files) {
523
+ const instance = new FileDiff({
524
+ theme: { dark: "pierre-dark", light: "pierre-light" },
525
+ diffStyle: "split",
526
+ overflow: "scroll",
527
+ enableLineSelection: true,
528
+ enableGutterUtility: true,
529
+ lineHoverHighlight: "both",
530
+ onLineSelected: (range: SelectedLineRange | null) => openDrawer(fileDiff.name, range),
531
+ onGutterUtilityClick: (range: SelectedLineRange) => openDrawer(fileDiff.name, range),
532
+ renderAnnotation: renderFindingAnnotation,
533
+ });
534
+ instance.render({ fileDiff, containerWrapper: diffRoot, lineAnnotations: [] });
535
+ }
536
+ }
537
+ ```
538
+
539
+ ## Agent result
540
+
541
+ After submit, send this as a user message:
542
+
543
+ ```md
544
+ # Diff Review Round N
545
+
546
+ ## Findings
547
+ - [high][bug] src/foo.ts:43 additions — comment
548
+
549
+ ## Notes
550
+ ...
551
+
552
+ Use this review to propose and discuss a fix plan first. Do not edit files yet.
553
+ ```
554
+
555
+ No findings:
556
+
557
+ ```md
558
+ # Diff Review Round N
559
+
560
+ No findings recorded.
561
+
562
+ Use this review result to decide whether any follow-up is needed. Do not edit files yet.
563
+ ```
564
+
565
+ ## Data model
566
+
567
+ ```ts
568
+ type ReviewCategory = "bug" | "style" | "perf" | "question";
569
+ type ReviewSeverity = "high" | "medium" | "low";
570
+ type ReviewSide = "additions" | "deletions";
571
+ type FindingStatus = "open" | "resolved" | "closed_auto";
572
+
573
+ type Finding = {
574
+ id: string;
575
+ round: number;
576
+ file: string;
577
+ prevFile?: string;
578
+ side: ReviewSide;
579
+ startLine: number;
580
+ endLine: number;
581
+ category: ReviewCategory;
582
+ severity: ReviewSeverity;
583
+ comment: string;
584
+ notes?: string;
585
+ status: FindingStatus;
586
+ statusReason?: "file_removed" | "anchor_missing" | "manual";
587
+ anchor: {
588
+ before: string;
589
+ selected: string;
590
+ after: string;
591
+ };
592
+ };
593
+
594
+ type ReviewSource = {
595
+ kind: "working-tree" | "staged" | "base";
596
+ base?: string;
597
+ files?: string[];
598
+ };
599
+
600
+ type ReviewRoundEntry = {
601
+ version: 1;
602
+ kind: "round";
603
+ round: number;
604
+ source: ReviewSource;
605
+ findings: Finding[];
606
+ notes?: string;
607
+ diffSummary: {
608
+ filesChanged: number;
609
+ additions: number;
610
+ deletions: number;
611
+ skippedFiles: string[];
612
+ };
613
+ createdAt: string;
614
+ };
615
+ ```
616
+
617
+ Custom session entry:
618
+
619
+ ```ts
620
+ pi.appendEntry("diff-review", reviewRoundEntry);
621
+ ```
622
+
623
+ Restore state:
624
+
625
+ ```ts
626
+ function restore(branch: SessionEntry[]): ReviewState {
627
+ const rounds: ReviewRoundEntry[] = [];
628
+ for (const entry of branch) {
629
+ if (entry.type !== "custom" || entry.customType !== "diff-review") continue;
630
+ const data = entry.data as ReviewRoundEntry;
631
+ if (data?.version === 1 && data.kind === "round") rounds.push(data);
632
+ }
633
+ return {
634
+ round: Math.max(0, ...rounds.map((r) => r.round)),
635
+ findings: rounds.flatMap((r) => r.findings),
636
+ };
637
+ }
638
+ ```
639
+
640
+ ## Diff index / anchors
641
+
642
+ Server builds a `DiffIndex` from unified patch before opening browser:
643
+
644
+ ```ts
645
+ type DiffLineMap = Map<number, string>;
646
+
647
+ type IndexedFileDiff = {
648
+ file: string;
649
+ prevFile?: string;
650
+ status: "added" | "modified" | "deleted" | "renamed" | "binary";
651
+ additions: DiffLineMap; // new-file line number → text
652
+ deletions: DiffLineMap; // old-file line number → text
653
+ };
654
+ ```
655
+
656
+ Anchor generation on submit:
657
+
658
+ - browser sends selection + form data only
659
+ - server looks up selected lines in `DiffIndex`
660
+ - `selected`: selected line texts joined with `\n`
661
+ - `before`: up to 3 available same-side lines before `startLine`
662
+ - `after`: up to 3 available same-side lines after `endLine`
663
+
664
+ If lookup fails, reject submit with `400` so browser can show stale-selection error.
665
+
666
+ ## Security
667
+
668
+ - bind `127.0.0.1` only
669
+ - random token, required for API writes
670
+ - no filesystem writes from browser
671
+ - sanitize/validate file filters before passing to git
672
+ - never invoke shell with interpolated args
673
+ - render user comments/paths via `textContent`
674
+ - cap diff size for v1, e.g. 5 MiB patch text; notify if too large
675
+ - clear token/session state on submit/cancel/timeout
676
+
677
+ ## Production browser smoke test
678
+
679
+ Add `scripts/smoke-browser.ts` before v1 completion.
680
+
681
+ Test must:
682
+
683
+ 1. run `bun run build`
684
+ 2. start extension static server with sample diff fixture
685
+ 3. open real browser using Playwright or available Chromium
686
+ 4. load tokenized URL
687
+ 5. assert no 404s for `static/*`
688
+ 6. assert no console errors
689
+ 7. assert Pierre tree renders file path
690
+ 8. assert Pierre diff renders changed line
691
+ 9. create one finding via DOM event/click path if possible, otherwise call UI fn behind test hook
692
+ 10. submit and assert server receives finding
693
+
694
+ If Playwright browser unavailable, smoke script exits with clear skip only in dev; CI/v1 release requires pass.
695
+
696
+ ## Implementation phases
697
+
698
+ ### 0. Preflight proof
699
+
700
+ - scaffold package
701
+ - install deps
702
+ - inspect local `node_modules/@pierre/*/dist/*.d.ts` after `bun install`
703
+ - build minimal browser bundle
704
+ - run smoke server/page manually or via `smoke:browser`
705
+ - verify no missing assets/console errors
706
+
707
+ Acceptance:
708
+
709
+ - `bun run build` creates `static/index.html`, `static/app.js`, `manifest.json`
710
+ - `bun run smoke:browser` passes or gives actionable missing-browser skip
711
+
712
+ ### 1. Minimal command + local server
713
+
714
+ - parse args
715
+ - `ensureBrowserBundle()`
716
+ - collect git diff + file summary + `DiffIndex`
717
+ - start tokenized localhost server serving full `static/` dir
718
+ - open browser or show manual URL
719
+ - wait for submit/cancel/timeout
720
+ - append `diff-review` entry
721
+ - send markdown user message
722
+ - `/diff-review-cancel`
723
+
724
+ Acceptance:
725
+
726
+ - `/diff-review` opens browser for unstaged diff
727
+ - missing/stale browser bundle auto-builds or fails with exact command
728
+ - opener failure still provides usable URL
729
+ - Submit returns to Pi and triggers agent turn with review markdown
730
+ - Cancel/timeout/session shutdown unblocks command and closes server
731
+
732
+ ### 2. Pierre UI
733
+
734
+ - `@pierre/trees` changed-file sidebar with verified `FileTree` API
735
+ - `@pierre/diffs` split diff list with verified `FileDiff` API
736
+ - line/range selection
737
+ - annotation drawer
738
+ - finding chips via `lineAnnotations`
739
+ - tree status badges via `gitStatus`
740
+
741
+ ### 3. Pi persistence
742
+
743
+ - append `diff-review` entries
744
+ - reconstruct current-branch state on `session_start`
745
+ - widget/status: `N open review findings`
746
+
747
+ ### 4. Reconciliation
748
+
749
+ On next review round:
750
+
751
+ - file missing → `closed_auto`, `statusReason: "file_removed"`
752
+ - selected anchor missing → `closed_auto`, `statusReason: "anchor_missing"`
753
+ - selected anchor found elsewhere → update line range
754
+ - user can manually resolve carried findings
755
+
756
+ ### 5. Polish
757
+
758
+ - dark/light sync with Pi theme if possible
759
+ - markdown export fallback only if later requested
760
+ - optional prompt injection of open findings before next agent turn
761
+ - optional worker pool for large diffs, only after serving/copying worker assets is verified
762
+
763
+ ## Non-goals for v1
764
+
765
+ - applying fixes directly from browser
766
+ - replacing normal `edit` rendering
767
+ - full Pi session tree browser
768
+ - untracked-file review
769
+ - non-interactive mode
770
+ - Pierre worker pool / WASM highlighter routing