Skip to content

feat(share): zero-knowledge plan review for brainstorm flows - #38

Merged
bfirestone merged 20 commits into
mainfrom
feat/add-shared-review
Apr 30, 2026
Merged

feat(share): zero-knowledge plan review for brainstorm flows#38
bfirestone merged 20 commits into
mainfrom
feat/add-shared-review

Conversation

@bfirestone

@bfirestone bfirestone commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an end-to-end zero-knowledge plan-review surface that lives at /share/[id]. The author runs arc share create <plan.md>, copies the URL (with the decryption key in the fragment), shares it with reviewers; reviewers annotate inline; the author accepts/resolves/rejects; the agent pulls accepted comments back via arc share pull <id> --json and applies the changes.

The feature is built in two layers:

  • Session 1 (commits cfcbce9a9beb46): foundation. Encrypted paste API in internal/paste/, optional standalone arc-paste binary, CLI subcommands (arc share create | list | comments | pull | approve | update | delete), SvelteKit SPA at /share/[id] with selection-anchored annotations, runbook.
  • Session 2 (commits 649d47e0f8c129): polish and agent-pipeline. Configurable author/server identity, reviewer + plan-author annotation editing, planner-aligned theme, fluid doc width, fixed markdown rendering, viewport-clamped overlays, platform-aware keyboard hints, scratch-based Docker deployment, and a single-object JSON bundle output designed for direct LLM consumption.

What ships

Server-side (Go)

  • New internal/paste/ package with HTTP handlers, opaque blob storage, AES-256-GCM crypto helpers, and a parallel anchor-resolution implementation that mirrors the SPA's 4-step fallback (exact → heading-scoped → fuzzy → orphaned).
  • New arc-paste/ standalone binary for public deployments separate from the main arc-server.
  • Mount of paste handlers in arc-server's existing Echo router for local-mode shares.

CLI (Go)

  • arc share * subcommands wired through ~/.arc/shares.json registry that holds the URL + edit_token + key per share.
  • New share_author and share_server config fields with full precedence chains (flag → config → env → fallback). For share_author the fallback chain ends at git config user.name; for share_server it ends at the built-in arcplanner.sentiolabs.io.
  • arc share comments --json emits a single bundle object: {plan: {id, title, file ⊕ markdown_b64}, comments: [{comment, status, reply, resolved_anchor: {status, line_start, line_end, snippet}}]}. The filemarkdown_b64 xor lets the agent infer source: file path when local, base64-encoded markdown when not. Designed for arc share pull <id> | <agent>.

Web SPA (Svelte 5)

  • /share/[id] route with a focused review surface (the standard arc sidebar is hidden via an isShareRoute guard so the route also works on a public-paste deploy with no projects API).
  • Selection-driven floating toolbar (👍 💬 🗑 💡 ⚡), comment/suggest popover, quick-label picker, inline annotation marks via DOM-walking, persistent right-rail with annotation cards.
  • Two roles can edit annotations: the original commenter and the plan author. Edits are append-only events; replay merges fields onto the original comment without changing the displayed author_name.
  • Fluid doc width via clamp(47.5rem, 80%, 60rem) so wide-screen viewports get breathing room without sacrificing the narrow-screen reading experience.
  • Theme aligned with /planner (Instrument Sans, surface-900 surfaces, indigo accents). Annotation-domain colors (amber comment, red delete, sage praise) preserved as semantic markers.
  • Markdown renderer rewritten to use marked.lexer()-based source-line tracking + the project's existing shiki pipeline (github-dark-dimmed theme). The previous implementation broke fenced code blocks containing blank lines.

Build / ops

  • arc-paste/Dockerfile rewritten as a 3-stage build with bun:1-alpine + golang:1.26-alpine + scratch runtime, -tags webui (was missing — would have shipped a binary that returned JSON 404 for /share/<id>), -trimpath and -ldflags='-s -w'. ~10MB final image.
  • arc-paste/compose.yaml with named volume for SQLite persistence, restart: unless-stopped, port 7433.

Documentation

  • New docs/runbooks/paste-server.md — manual test runbook covering local + remote modes, Docker option, troubleshooting table.
  • New docs/review_howto.md — Accept/Resolve/Reject semantics for plan authors, oriented around the LLM-consumer goal, with the JSON bundle schema documented.

Test coverage

  • Go: paste storage + crypto + handlers, anchor resolution (with cross-language slugify compatibility), CLI roundtrips, replay with edits + plan-author edits + forged-edit rejection, JSON bundle shape (local file + remote no-file cases), author/server resolution precedence.
  • TypeScript (Vitest): event replay (regular + edit + plan-author edit), positioning clamp, platform detection, anchor resolution, paste client/crypto.
  • Total: ~70 new tests across both languages.

Test plan

  • make build — confirm bin/arc and bin/arc-paste produced with webui tag (smoke check: curl http://localhost:7432/share/foo should return SPA HTML, not JSON 404).
  • cd web && bun run check && bun run test — should be 0 errors / 0 warnings / all tests passing.
  • go test ./... — all packages green.
  • Manual flow (per docs/runbooks/paste-server.md):
    • Start ./bin/arc server start --foreground, run ./bin/arc share create /tmp/p.md --local, open URL.
    • As author: highlight text → 💬 Comment → save. Verify amber <mark> appears inline + card in right rail.
    • Highlight + 🗑 Delete → red strikethrough inline + DELETE chip in card.
    • Highlight + 💡 Suggest with replacement text → green replacement block in card.
    • As reviewer (incognito with different name): post a comment, refresh author window, see it appear.
    • As author: ✓ Accept one comment, ✕ Reject another (with reply), — Resolve a third.
    • As reviewer: click ✎ Edit on own open comment, refine wording, save. Confirm · edited Nm indicator.
    • As author: click ✎ Edit on a reviewer's comment, refine wording. Confirm displayed author_name stays as the reviewer.
    • ./bin/arc share comments <id> --json | jq . — verify bundle shape with plan.file, plan.markdown_b64 absent, resolved_anchor.status: "ok", etc.
    • ./bin/arc share pull <id> — verify only accepted comments print.
  • Docker path: docker compose -f arc-paste/compose.yaml up -d --build, smoke a remote share against http://localhost:7433.
  • Cross-browser: confirm Ctrl ⏎ shows on Linux/Windows, ⌘ ⏎ on macOS.
  • Wide viewport: confirm doc width grows from 760px (1200px viewport) to 960px (≥1600px viewport) without breakpoint snaps.

Establishes the shared contracts for the shared-review epic: Go types
(PasteShare, PasteEvent, DTOs), Storage interface with sentinel errors,
SQLite implementation with embed-based migrations, AES-256-GCM crypto
helpers, and TypeScript mirrors with Vitest unit tests.
Implements five endpoints (POST, GET, PUT, DELETE, POST blobs) with
Bearer token auth, proper HTTP status codes, and 10 test cases covering
happy paths, auth errors, and missing resource cases.
Implement four utility modules for the paste/shared-review feature:
client.ts (fetch wrappers), anchor.ts (4-step resilience resolution),
events.ts (CRDT-lite replay), and identity.ts (localStorage name capture).
All modules are fully tested with 16 passing Vitest unit tests.
Implements the SvelteKit route web/src/routes/share/[id]/ with all 11
components for the encrypted plan review UI: PlanRenderer (markdown with
source-line anchors), AnnotationToolbar, LabelPicker, CommentCard,
CommentThread, ResolveControls, SuggestionDiff, NamePromptModal, and
DriftBadge. The route decrypts pastes via window.location.hash key,
replays comment events, and gates submission on a reviewer name prompt.
Wire the paste package into arc-server so that /api/paste/* endpoints
are available at startup. Paste migrations run automatically on the same
SQLite DB as arc's main storage. The SPA fallback already handles
/share/[id] via the existing embed_webui.go handler.

- Add pastesqlite.Apply call in initSchema after arc migrations succeed
- Add DB() *sql.DB accessor to sqlite.Store for use by route registration
- Create paste_routes.go with registerPasteRoutes(e, db) helper
- Pass DB to api.Config in server.go (conditional: no-op when nil)
- Wire store.DB() into api.Config in internal/server/server.go
- Add TestPasteRoutesMounted and TestShareRouteFallsBackToSPA tests
Implements `arc share create|list|show|comments|pull|approve|update|delete`
with client-side AES-GCM encryption via the paste package and persists
share metadata (key, edit_token) to ~/.arc/shares.json (mode 0600).
Replaces the initial review surface with an Editorial Marginalia aesthetic
(dark paper, Newsreader serif body, ink-color metaphor) and a plannotator-style
floating toolbar. The selection-driven workflow now offers six distinct intents
(praise, comment, delete, suggest, quick-label, dismiss) instead of the original
inline AnnotationToolbar.

- Adds scoped --ink-* design tokens under .share-page in app.css
- Skips arc's sidebar + projects fetch on /share/* via isShareRoute guard
- Wraps quoted_text inline with <mark> via DOM walk (inline-annotations.ts)
- Splits the right rail into AnnotationsPanel + AnnotationCard
- Pulls comment/suggest into a single CommentPopover
- Adds QuickLabelPicker for ⚡ menu with 1..5 keyboard shortcuts
- Renders floating overlays inside .share-page so they inherit ink tokens
- Adds action: 'comment' | 'delete' field to CommentEvent
- Adds @tailwindcss/typography devDep

Removes superseded components: AnnotationToolbar, CommentCard, CommentThread,
DriftBadge, LabelPicker, ResolveControls, SuggestionDiff. Threading, suggestion
diff rendering, and drift badges are deferred (see status doc).
Three small, low-risk cleanups bundled together:

- Scope vitest's include glob to src/lib/paste/** so it doesn't pick up
  legacy bun:test files elsewhere in web/.
- Replace `as any` gymnastics in paste/crypto.ts with a single asArrayBuffer()
  helper. Functionally identical, just clearer.
- Run `bun run format` + lint:fix across web/. No behavioral changes — only
  trailing commas, line breaks, and quoting normalized by Biome/Prettier.

The format pass touches a lot of files that look unrelated; verifying with
`git diff --stat` shows no functional edits outside the paste lib.
Without the webui build tag, arc-paste's embedded SPA assets are zero bytes
and GET /share/<id> returns a JSON 404 from the API fallback instead of
serving the SvelteKit app shell. Mirrors the flag the main `build` target
already passes.
Step-by-step manual verification flow for the share/[id] surface, covering
local mode (arc share create --local) and the deferred public arc-paste
deploy. Codifies what was previously tribal knowledge from manual SSH-tunnel
testing.
CLI
- Capture author identity at create time via new resolveAuthor helper:
  --author flag > share_author in cli-config.json > $ARC_SHARE_AUTHOR >
  `git config user.name`. Without an author, plan.author_name stays empty
  and the share UI's Accept/Resolve/Reject controls never appear — warn
  loudly when this happens.
- New share_server config field with the same precedence (flag > config >
  env > built-in default). Default URL is now arcplanner.sentiolabs.io.
- Wire up `--title` flag with filename-stem fallback so the SPA header
  shows a real title instead of "Untitled plan".
- printComments now applies edit events, mirroring the SPA's replay so
  `arc share comments` / `arc share pull` see refined wording, not stale.

Web
- Replace asymmetric-inset doc layout with mx-auto centering: equal gaps
  between the viewport edge → doc and doc → rail seam at any width.
- Add reviewer self-edit: new kind="edit" event references a comment by
  id and merges body / suggested_text / comment_type at replay time.
  Author gate (edit.author_name === comment.author_name) lives in the
  replay layer since the encrypted server can't enforce it. Inline edit
  UI in AnnotationCard appears only on the reviewer's own open comments;
  ⌘/Ctrl-⏎ saves, esc cancels. Card shows "edited Nm" indicator.
- Clamp floating overlays (CommentPopover, QuickLabelPicker,
  FloatingToolbar) to viewport bounds so they don't clip off-screen
  when the selection is near a margin.
- Detect macOS via userAgentData.platform → navigator.platform fallback;
  show ⌘ on Mac, Ctrl elsewhere in keyboard hints.

Tests
- 7 new replay-event tests (edit application, forged-edit rejection,
  multiple edits, keep-vs-clear semantics, orphan handling).
- 6 positioning-clamp tests, 8 platform-detection tests.
- TestResolveAuthor and TestResolveServer lock the precedence chains.
- TestRunShareCommentsAppliesEdits verifies CLI parity with SPA replay
  including a Mallory-forges-Steve's-edit case.

Docs
- New docs/review_howto.md explaining Accept/Resolve/Reject semantics
  for plan authors, oriented around the LLM-consumer workflow.
- Runbook updated with the new author/server config precedence and
  the reviewer self-edit step.
Dockerfile rewritten as a 3-stage build:
  - Stage 1: bun:1-alpine builds the SvelteKit SPA (matches local toolchain).
  - Stage 2: golang:1.26-alpine compiles a static binary with `-tags webui`,
    `-trimpath`, and `-ldflags='-s -w'` for a smaller, reproducible artifact.
    The previous Dockerfile was missing `-tags webui`, which would have
    produced a binary where /share/<id> returned Echo's default JSON 404
    because web.RegisterSPA was the no-op stub.
  - Stage 3: scratch — just the binary, ~10 MB final image.

arc-paste/main.go now MkdirAll's the SQLite parent dir on startup, so
fresh volumes / nested DB paths work without an init step. This matters
on scratch where the binary can't rely on a base image creating /data,
and helps any deploy that points ARC_PASTE_DB at a path the host hasn't
pre-created.

arc-paste/compose.yaml ships alongside, with a named volume for SQLite
persistence, port 7433, restart: unless-stopped, and a comment explaining
why no in-container healthcheck (scratch has no shell). Build with:
  docker compose -f arc-paste/compose.yaml up -d --build

Runbook updated with the Docker option in the remote-review section.
Two related improvements to the reviewer→LLM pipeline:

1. arc share comments --json now emits a single JSON object designed
   for direct LLM consumption:

   {
     "plan": { id, title, author_name,
               file (when local) | markdown_b64 (when not) },
     "comments": [{ comment, status, reply,
                    resolved_anchor: { status, line_start, line_end,
                                       snippet } }]
   }

   - The `file` ⊕ `markdown_b64` split avoids dragging the plan content
     through every CLI call when the agent already has it on disk.
     Base64 sidesteps JSON's escape penalty (every \n and \" doubles
     the byte count and destroys readability) when content IS shipped.
   - `comment.action` is now preserved on the Go side so delete
     annotations round-trip correctly (was silently dropped before).
   - `resolved_anchor` runs the SPA's 4-step anchor fallback (exact →
     heading → fuzzy → orphaned) against the live plan content, so
     line numbers are ground-truth for what the agent will edit.
   - New internal/paste/anchor.go ports anchor.ts to Go; 9 tests cover
     every fallback path plus a TS-compatibility check on Slugify.

2. Plan author can now edit any reviewer's annotation, not just their
   own. The displayed comment.author_name doesn't change — only the
   body/suggested_text/comment_type — so attribution is preserved and
   the audit trail (in the underlying edit event) records who actually
   edited. This lets the author sharpen thin "expand this more"
   feedback into something LLM-actionable without round-trips.

   Replay-time auth gate updated in both events.ts and share.go:
   accepts edits where author_name matches the comment's original
   author OR the plan's author (with non-empty planAuthor guard so
   empty-string-matches-empty-string doesn't grant rights).

Tests
- TestRunShareCommentsJSONBundle: bundle shape, action round-trip,
  resolved_anchor against the on-disk file, snippet content.
- TestRunShareCommentsJSONBundle_NoLocalFile: simulates a cross-machine
  agent (wipes shares.json mid-test) and verifies markdown_b64 round-
  trips with newlines + quotes preserved.
- TestRunShareCommentsAppliesPlanAuthorEdits: Steve's body becomes
  Ben's wording; Mallory's forged edit dropped; Steve still attributed.
- 4 new sub-tests in events.test.ts mirror the auth corners (plan-
  author edit, third-party rejection, empty-author trap, chronological
  ordering across both roles).
- 9 anchor_test.go tests including out-of-range fall-through and
  TS-compatible slugify.

Docs
- review_howto.md: full JSON bundle schema with the two cases (local
  / remote) and field-by-field guide for agent consumers. New
  "Editing annotations" section explains the two-role edit model.
- runbooks/paste-server.md: step 8 added — manual test of author
  editing a reviewer's comment.
… toolbar

Three intertwined improvements to the /share/[id] surface:

1. Fix the broken plan renderer
   The previous PlanRenderer split markdown on blank lines to attach
   per-block `data-source-line` attributes for selection anchoring.
   That destroyed fenced code blocks containing blank lines: the SQL
   schema, the TypeScript type definitions, and the `arc share *` CLI
   block all rendered as flowing prose with `# comment` lines becoming
   <h1> headings and the rest of the fence body becoming <p>.

   Fixed by switching to `marked.lexer()` to enumerate top-level tokens
   (each fenced block is ONE token, blank lines and all). Per-token
   line numbers come from `token.raw`'s offset in the source. The full
   document renders once through the project's existing shiki-powered
   `renderMarkdown` (github-dark-dimmed theme) for syntax highlighting,
   then DOMParser walks the result and tags each top-level element with
   data-source-line directly — no wrapper divs.

2. Match /planner's color theme
   The share page's editorial Newsreader/warm-amber palette read as a
   visual outlier inside arc. Now `--ink-paper`, `--ink-text`,
   `--ink-rule` etc. alias to arc's `--color-surface-*` /
   `--color-text-*` / `--color-border` tokens, the doc inherits
   Instrument Sans, headings drop the italic display style, bullets
   become standard discs, and inline code uses the planner's indigo-
   on-raised-surface treatment.

   Annotation-domain tokens (`--ink-comment` amber highlight,
   `--ink-delete` red, `--ink-praise` green) stay distinct since
   they encode review semantics, not chrome. `--ink-delete` and
   `--ink-praise` are now derived from `--color-status-blocked` and
   `--color-status-open` via `oklch(from var(...) l c h / alpha)` so
   any future palette tuning carries through automatically.

3. Merge the two action rows in AnnotationCard
   When the plan author viewed an editable comment, they saw two
   separate bordered toolbars: one for ✎ Edit, another for
   Accept/Resolve/Reject. Single-row treatment with a typographic
   `·` separator (the same glyph used elsewhere in the card for
   metadata) reads as one decision row instead of two stacked
   choices. Color hierarchy already differentiates the soft action
   (Edit, muted text) from the load-bearing decisions (Accept green,
   Reject red), so no extra styling needed.

Tests
  44 web tests still pass; svelte-check clean. No test changes — these
  are visual / rendering-pipeline fixes covered by the existing replay
  + components test suite.
…screens

The doc was hard-capped at max-w-[760px] regardless of viewport, so
1200px and 1920px monitors had identical doc widths with the latter
showing ~400px of empty space between the doc and the rail.

Switch to a single clamp() that interpolates smoothly between three
principled bounds:

  floor 47.5rem (760px) — current width; viewports that already feel
                           comfortable don't change behavior.
  pref  80%             — 80% of the doc-area (= 1fr column = vw - 360
                           rail). Grows fluidly with available space.
  ceil  60rem  (960px)  — readable upper bound. Past ~95ch the eye's
                           saccade between line-end and next-line gets
                           too long for comfortable prose. Code blocks
                           tolerate the extra width fine.

Effective widths by viewport:
  1100px (doc-area 740): 740 (parent caps)        — same as before
  1200px (doc-area 840): 760 (floor wins)         — same as before
  1440px (doc-area 1080): 864 (80% pref)          — +104
  1600px (doc-area 1240): 960 (ceiling)           — +200
  1920px+:                960 (ceiling)           — +200, stable

Static `max-w-[760px]` removed from the markup so the CSS clamp is
the single source of truth — no Tailwind/CSS conflict.

Side benefit: ASCII-art code blocks (e.g. the architecture diagram in
the shared-review plan) fit at 960px without horizontal scroll. The
narrow-viewport reading experience is unchanged.
…ages

Brings the repo back to a clean lint state after the share/paste feature
landed. Highlights:

- Rename paste.PasteShare/PasteEvent → paste.Share/Event (revive exported
  rule) and update all call sites.
- Hoist magic numbers to named constants (share ID length, edit token
  bytes, heading window, file modes, etc.).
- Replace deprecated middleware.Logger with RequestLoggerWithConfig in
  arc-paste; restructure main into run() error so deferred db.Close runs.
- Decompose printComments (cognitive complexity 52 → small helpers),
  resolveShareRef nesting, and TestResolveServer table-driven.
- Move paste tests to package paste_test; use t.Setenv; tighten test file
  perms to 0o600; add //nolint:gosec with reasons where the variable URL
  is the deliberate CLI behaviour.
- Add ErrShareNotFound to sharesconfig (replacing nil/nil return) and
  thread errors.Is checks through callers.
@bfirestone
bfirestone merged commit 54aefff into main Apr 30, 2026
3 checks passed
@bfirestone
bfirestone deleted the feat/add-shared-review branch April 30, 2026 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant