Commit Graph

1173 Commits

Author SHA1 Message Date
Michael Ramos 62c1eab119 chore: bump version to 0.26.7 v0.26.7 2026-08-09 23:02:38 -07:00
Michael Ramos 608a8003aa feat(annotate): hit-test pinpoint targeting for raw-HTML sessions (#1251)
* feat(annotate): hit-test pinpoint hover instead of the semantic whitelist

Pinpoint hover in raw-HTML sessions now resolves the real element under
the cursor via deep elementFromPoint (piercing open shadow roots) instead
of the SEMANTIC_* tag-whitelist graph, so styled div/span prototypes
(chips, icon buttons, cards) are individually targetable. Scope control
stays mouse-only and geometric, matching the markdown surface: the
deepest element under the pointer wins; pointing at a container's padding
or any area not covered by a child selects the container.

- Exclusions are identity-based: html/body, script/style, and an identity
  Set of viewer overlay nodes (pin badges, pinpoint box/label, vim UI) so
  page markup cannot spoof its way out of targeting. Annotation marks are
  transparent (resolve to their parent).
- Tiny-element promotion: a leaf under 16px on both axes climbs to the
  nearest ancestor at least 16px on one axis (MIN_CAPTURE_SIZE floor,
  not a whitelist). SVG shape primitives still promote to their <g>.
- Hover no longer builds the semantic target graph per pointer frame
  (graphForPointerFrame removed); it is per-event hit-testing with a
  2px/16ms last-position cache that the scroll reconcile invalidates.
  The graph survives untouched as the vim-navigation vocabulary.
- Hover labels for generic containers use a cascade: aria-label -> role
  -> 1-2 meaningful class tokens (hash-ish tokens stripped) -> own text
  when <= 40 chars -> 'container'. Known semantic tags keep their names.
- Click annotates exactly what the hover box shows; text-less elements
  post '[element: <label>]' instead of being untargetable.
- Anchors stay fail-closed: data-test-id/data-cy/data-qa join the
  author-controlled identity attrs, and text-less elements store a
  '[[pn-shape]]' signature (tag + sorted classes + child count + 16px
  size buckets) verified whole-string on restore.

* test(annotate): cover the pinpoint hit-testing contract

New DOM regression tests modeled on the signoff prototype pages:
chip/button targeting on styled div/span markup, container selection via
uncovered-area pointing, tiny-element promotion (mocked 8x8 dot promotes
to its 200x100 card), the generic-container label cascade, the text-less
shape-signature anchor round trip (verify + fail-closed rejection on
shape drift), data-cy identity-attr trust, and an instrumented assertion
that hover hit-testing performs zero document-wide querySelectorAll
calls across 30 mousemoves. Also reattaches the cached hover label
element when the body was replaced (parity with the hover box), and
accepts the shape-prefixed snapshot through the parent-side DTO test.

* fix(annotate): drop shape-signature anchors; fail closed on text-less weak selectors

Review findings on the pinpoint hit-testing branch:

- D1 (blocker): structure-derived shape signatures are identical across
  identical siblings by construction, so a positional selector whose
  sibling was removed resolved the WRONG element while the signature
  still verified — the exact wrong-binding failure the anchor system
  exists to prevent. The mechanism is removed entirely: text-less
  elements now anchor ONLY when the element itself carries a
  stable-identity rung (#id or the data-* identity attrs); otherwise no
  anchor ships and the pin simply does not restore (same as before the
  branch, fail closed). A wrong-binding anchor is worse than no anchor.
  D2-D4 (in-band prefix discriminator, uncapped class-list component,
  viewport-dependent size bucket) die with it.
- D5: the pinpoint click handler gated pin-badge ownership on a
  [data-plannotator-pin-badge] selector match, contradicting the
  identity-only overlay rule. It now gates on the overlay identity set:
  a page element spoofing the attribute hovers and annotates like any
  other element, while real badges keep owning their clicks.
- D6: class-token hover labels now route through the 40-char label cap
  (two long tokens could reach 73 chars), and the '[element: ...]'
  posted text goes through capSelectionText for consistency.
- D7: the pointer-hit cache invalidation moved to the top of the rAF
  reconcile, ahead of its guards, so every reconcile pass (scroll,
  resize, and body ResizeObserver — i.e. layout-changing mutations)
  clears it; the 2px/16ms TTL bounds any remaining staleness to one frame.

Tests updated to the new contract, with regressions for D1 (identical
text-less siblings ship no anchor; crafted positional anchors with empty
snapshots resolve nothing after sibling removal), D5 (spoofed badge is
annotatable, real badge posts mark-click), and the D6 label cap.
2026-08-09 23:01:31 -07:00
Michael Ramos d579ff8db2 chore: bump version to 0.26.6 v0.26.6 2026-08-09 21:07:30 -07:00
Michael Ramos e53a933106 fix(ci): bump Bun build pin to 1.3.14 for sandbox env loading (#1249) (#1250)
Bun <= 1.3.11 loads an empty process.env when a cwd ancestor directory
is unreadable, the normal state inside OS sandboxes (Seatbelt/Landlock):
every released binary silently ignored all PLANNOTATOR_* env vars there
(oven-sh/bun#27802, fixed in 1.3.13). The 1.3.11 pin existed for the
Bun 1.3.12 cross-compile signing regression (#541, binaries SIGKILLed
on macOS); verified gone on 1.3.14: cross-compiled darwin-arm64 output
carries the same linker-signed CodeDirectory as the known-good shipped
binaries and executes cleanly on macOS 26.3, and the unreadable-ancestor
env repro passes on a 1.3.14 build.

Guardrails so neither regression class can ship silently again: the
release smoke matrix gains a macOS arm64 leg (executing the binary is
the signing assertion), and a new smoke step runs the annotate server
from a cwd with an unreadable ancestor, where responding on the fixed
PLANNOTATOR_PORT proves env vars were read.
2026-08-09 21:06:26 -07:00
Michael Ramos 9c40ffadcc chore: bump version to 0.26.5 v0.26.5 2026-08-09 17:49:14 -07:00
Michael Ramos 2d61b76c44 fix: address the three pre-release sweep findings (#1246)
Three real, new-in-range bugs from the free-hunt regression sweep:

- The pinpoint anchor builder ran a document-wide uniqueness query per
  ancestor against a growing selector with no depth cap, so one click on
  a deeply wrapped document froze the tab synchronously (measured 58s at
  depth 800). The walk now abandons the anchor past 40 ancestors (fail
  closed: text-search restoration takes over), bounding the work to
  interactive time. Regression test uses two identical depth-60 chains
  so uniqueness cannot short-circuit before the cap.

- The 10k selection-text cap sliced UTF-16 code units and could split a
  surrogate pair at the boundary, silently corrupting the annotation
  tail into U+FFFD downstream. Both sides of the bridge now back the cut
  off one unit when it would land mid-pair.

- The old-git sparse fallback matched git's "unknown option" error
  literally, which localized git builds translate, so non-English users
  on old git hit the exact hard failure #1239 was written to fix. All
  three installers now pin LC_ALL=C around the probe clone (saved and
  restored; kept single-line in install.ps1 for the scoped-call
  scanner, whose expectation is updated to the new prefix).
2026-08-09 17:48:05 -07:00
Michael Ramos 4d9147200d ci: wire 8 orphaned DOM-gated test files into the DOM test step
The DOM step runs an explicit file list, and eight describe.if(hasDom)
suites were never added to it, so they silently reported 0 pass / all
skip on every CI run: the three new #1243 suites (htmlPinpointProtocol,
htmlChrome, inputMethod) plus five older vim/highlight suites (vimHud,
Viewer.vimMode.integration, useVimSelection, codeHighlight,
vimNavigation). Found by the pre-release sweep. All 69 tests pass
together locally under the exact CI invocation.
2026-08-09 17:33:32 -07:00
Michael Ramos 9b14b19a7b chore: fold in pre-release QA findings (print overlays, doc alignment) (#1245)
* chore: fold in pre-release QA findings (print overlays, doc alignment)

From the 25-item QA sweep over the v0.26.4..main range (all items passed;
these were the three real minors worth folding into the release):

- The raw-HTML iframe's injected CSS had no print rules, so pin badges
  (and, in a narrow window, the pinpoint outline box) printed into
  hard copies of annotated HTML pages. The overlay elements now carry an
  explicit @media print hide inside the iframe document, where the outer
  print.css cannot reach. Inline annotation marks stay printable on
  purpose, matching markdown documents.
- AGENTS.md/CLAUDE.md now state that PLANNOTATOR_ANNOTATE_HISTORY also
  gates the durable submitted-feedback records from #1237, and the
  Annotation interface listing includes the htmlAnchor field from #1243.
- apps/codex/README.md aligns with the #1241 top-level wording (Windows
  Codex hooks are experimental with printed manual steps, not disabled).
- Marketing docs: annotate page documents the pinpoint-first default and
  minimal-first chrome for raw-HTML sessions; installation page notes
  the old-git plain-clone fallback from #1239.

* chore: drop deprecated marketing-docs edits (canonical docs are Mintlify)
2026-08-09 17:19:50 -07:00
Michael Ramos f2ac2adb59 docs: align README with installer on Windows Codex hook status (#1241) 2026-08-09 16:45:16 -07:00
Michael Ramos 005c32c7c8 ci: remove the install-script auto-sync job (manual sync only)
The deploy-install-scripts job added in #1214 assumed the CI deploy role
could write to the plannotator-install-scripts bucket. It cannot, by
design, and the job's first-ever real execution (the #1239 installer
change) failed on AccessDenied. Install-script syncs are manual with
local credentials; the workflow now carries a comment pointing at the
release runbook's exact commands instead of a job that cannot succeed.
2026-08-09 16:24:47 -07:00
Michael Ramos 9ab5392bad feat(annotate): pinpoint-first raw-HTML sessions with element anchors and a minimal-first render (#1243)
* feat(annotate): rebuild HTML pinpoint mode with element anchors, pin badges, and a minimal-first render

Raw-HTML annotate sessions now open as just the page: first-ever run hides
all annotation chrome (toolstrip, tongue tabs, action cluster, sidebar) and
the user's last chrome state is restored on every later session via the
plannotator-html-chrome cookie. Pinpoint becomes the default input method on
the HTML surface, persisted separately from the markdown preference.

Pinpoint mechanics are rebuilt in the sandbox bridge, modeled on
app-notes-extension and agentation: a fixed-position outline box + label
replaces class writes on author elements, hover is identity-gated and
re-hit-tested on scroll/resize via a rAF-coalesced reconcile pass, clicks
pin the element (crosshair cursor, pinned outline while composing) and go
straight to the comment popover. Each pin serializes a verified-unique CSS
selector anchor (id > identity attrs > meaningful classes > nth-of-type
path) with a text-snapshot fingerprint; restoration is anchor-first with
fail-closed validation and falls back to document-wide text search. Element
pins that cannot take an inline mark (SVG etc.) get numbered pin badges that
track their element and re-acquire it after re-renders.

All DOM inspection stays inside the bridge; only validated, size-capped DTOs
cross postMessage. Annotation model is extended additively (htmlAnchor), so
exported feedback, drafts, and share links keep working.

* fix(annotate): harden HTML element anchors per adversarial review

Four fixes from the pre-merge adversarial review of the pinpoint round:

- Stable identity is now only the element's own #id or data-* rung,
  re-derived from the resolved element and compared whole instead of
  parsed out of the selector string. Behavioral attributes (role, href,
  aria-label, name, alt) no longer exempt an anchor from the text check,
  so a regenerated page can no longer bind an annotation to the wrong
  element. A weak anchor with a missing or empty snapshot is rejected,
  not exempted.
- Restoration order: the document-wide text search now runs before the
  pin-badge fallback, so text that moved elsewhere in a regenerated page
  is followed rather than badging the stale container. Resolved SVG
  anchors still pin directly.
- Selection text is capped at 10k chars on both sides of the bridge
  (truncated, not rejected); one pinpoint click on a huge pre/table no
  longer ships an unbounded page-controlled string into drafts,
  feedback, or share URLs.
- buildAnchorSelector returns null instead of a terminal fallback path
  already proven non-unique; no anchor beats a known-ambiguous one.

Also moves the HTML chrome restore out of the one-shot mount effect into
a surface-transition effect, so a linked .html doc opened from a
markdown session gets the minimal-first chrome and persists its state,
and a markdown surface's sidebar use never leaks into the HTML cookie.

* fix(annotate): skip the chrome save in the restore commit itself

The reviewer's residual finding: htmlChromeRestoredRef flips synchronously
inside the restore effect, but the restored state lands a commit later, so
the save effect's run in the restore commit wrote pre-restore values over
the remembered state (self-corrected next flush, but a page ending between
the two writes would freeze the inverted value). The restore now arms a
skip-one flag the writer consumes, so the stale write never happens. A
follow-up save fires from the changed deps when the restore changed
anything; when it changed nothing the cookie already holds those values.

Regression test instruments every chrome cookie write on a returning-user
mount and asserts none ever differs from the remembered state (mutation
tested: removing the skip fails it).
2026-08-09 16:17:06 -07:00
Raúl 183cae2c44 fix(vim): keep j/k cursor clear of HUD bands when scrolling (#1154)
Document Vim navigation moved the cursor with
`Element.scrollIntoView({ block: 'nearest' })`, which parks the target
flush against the nearest viewport edge — exactly where the sticky action
bar (top) and the key HUD / status pill (bottom) float. Motion to the top
or bottom of a document then hid the caret behind an overlay, while a
mouse wheel (which the browser lets overshoot) kept the same line nearer
centre.

Add vimScroll.ts: a pure computeVimScrollDelta returning the signed
scrollTop delta needed to clear a HUD band at each edge (0 when the
target is already inside the safe band; a target taller than the band
aligns to its top edge so reading order wins), resolveVimScrollMargin
sizing the fallback band as clamp(20% of viewport height, 24px..160px),
and a scrollVimTargetIntoView wrapper.

Both bands are measured from live geometry instead of guessed constants:
the top band widens past the ratio margin to clear the sticky action
bar, and the bottom band derives from the portaled key HUD / mode pill
rects ([data-vim-key-hud] / [data-vim-mode-badge]). The opt-in key HUD
(fixed bottom: 150, height: 88 — a ~238px band, past the 160px clamp) is
actually cleared, while the default pill reserves ~49px instead of
over-reserving 160px. An expanded key HUD is a deliberate modal state
that can outgrow the viewport, so it keeps the ratio margin.

The scrolling element is the native-scroll host fed through
ScrollViewportContext, so the wrapper takes that element from the caller
(useScrollViewport() in Viewer, the same node the reticle measures
against) and falls back to the historical scrollIntoView when it is
absent, so behaviour never regresses.

Route every cursor/target move in useVimSelection through it, and add
vimScroll.test.ts to the DOM allowlist in test.yml so its integration
tests run in CI.
2026-08-09 16:16:59 -07:00
Michael Ramos e24bd8464f fix(annotate): persist submitted feedback before deleting the draft (#678) (#1237)
* fix(annotate): persist submitted feedback before deleting the draft (#678)

* fix(annotate): scope durable submit records to single local files

Adversarial verification found the durable record had no mode gate: an
annotate-last or URL session, which was completely stateless before,
would persist submitted feedback quoting the agent's message or the
fetched page under history/, widening the documented annotateHistory
contract without a docs change. The record now shares the exact
eligibility gate the version history uses (mode annotate, non-URL path),
so previously-stateless modes stay stateless.

Also makes persistSubmittedDecision defensive about body types:
/api/feedback does no validation (unlike /api/approve), and a non-string
feedback previously flowed through settle() untouched with a 200; the
new .trim() guard turned that into a thrown 500 after the decision had
already settled. Malformed values now degrade to the exact legacy
behavior (settle, delete draft, 200) instead of throwing.

Both changes mirrored in the Pi server, with regression tests in both
runtimes: stateless modes write no record, and a malformed feedback body
returns 200 with the draft deleted and nothing persisted.
2026-08-09 16:16:57 -07:00
Michael Ramos 65b0b739a9 fix(install): fall back to a plain shallow clone when git lacks --sparse (#1238) (#1239) 2026-08-09 16:16:51 -07:00
Will Hampson 033368ede4 Fix feedback delivery after Pi reload (#1240) 2026-08-09 16:16:48 -07:00
Michael Ramos d5ae439f7a chore: bump version to 0.26.4 v0.26.4 2026-08-07 15:13:27 -07:00
Michael Ramos 3dd80f37f0 fix(skills): remove the human-only menu treatment and its hover jitter (#1236) 2026-08-07 15:12:35 -07:00
Michael Ramos c760fc522b chore: bump version to 0.26.3 v0.26.3 2026-08-07 14:32:16 -07:00
Michael Ramos ffd49080ee fix(skills): harden skill references before first release (#1235) 2026-08-07 14:22:58 -07:00
Michael Ramos 2edad89dfe fix(editor): stop the skill-content priming effect from re-render looping (#1234) 2026-08-07 14:15:35 -07:00
Michael Ramos 710ce1a065 docs: correct archive sharing posture (#1176) 2026-08-07 12:29:51 -07:00
Michael Ramos 7ad4d39ed9 feat(comments): reference agent skills with / or $ in plan review and annotate comments (#1229)
* feat(comments): reference agent skills with / or $ in plan and annotate comments

Typing / or $ at the start of a word in the document-UI comment composer
opens a picker of the user's global agent skills (~/.claude/skills,
~/.codex/skills, ~/.agents skills roots), served by a new GET /api/skills
on the plan and annotate servers in both runtimes (Bun + Pi mirror).
Multiple references per comment are supported; references live in the
comment text itself and are appended to exported feedback as a
'Skills referenced' block so the acting agent knows which skills to apply.

Human-invocation-only skills (disable-model-invocation: true frontmatter)
stay listed and selectable but render dimmed with a badge, warn in the
menu and composer, and are marked in the export so the agent is never
asked to invoke something it cannot.

Discovery reuses the review-skill loader (same roots, precedence, and
skip-and-log discipline), reads only an 8KB head per SKILL.md, caps the
catalog at 500 skills, takes no client input, and is never persisted;
any failure degrades to plain typing.

* fix(comments): harden skill references per review (trigger, IME, seam, fail-closed frontmatter)

Blockers:
- B1: a trigger now requires at least one query character. A bare / or $
  no longer opens the catalog, so Enter stays a newline and Tab still
  leaves the field ("This costs $" + Enter, "cd /" + Tab, bullets).
- B2: the menu ignores keys mid-IME-composition (nativeEvent.isComposing),
  matching the 16 existing guards; Enter committing a Pinyin/Telex/Korean
  candidate can no longer insert a skill.
- B3: the catalog request is a host seam (skillCatalogTransport via
  configurePlannotatorUI), defaulting to the existing GET /api/skills.
- B4: resetSkillCatalogCache() invalidates outstanding requests
  (generation counter), and a late-resolving stale request can no longer
  overwrite a newer cached value or the export registry. The catalog
  tests reset in beforeEach, so they hold in any file order.

Also:
- F1: skillReferences={false} is fully inert — the human-only notice memo
  and the cache seed are gated on the prop.
- F4: frontmatter flag parsing no longer fails open: trailing YAML
  comments are stripped, on/1 (and TRUE/yes etc.) read as true, the head
  read is 64KB, and truncated unterminated frontmatter fails CLOSED on
  disable-model-invocation.
- F5: extraction ignores markdown link destinations ([x](/name)), shell
  redirects (cat /x > out), and /-triggered FHS root names (/run, /tmp);
  menu insertion switches / to $ for those names so inserted references
  always survive extraction.
- F6: the 500-skill cap slices after sorting, so which skills survive no
  longer depends on readdir order.
- F3: /api/skills wiring guards for the Bun and Pi plan + annotate
  servers (skills-endpoint.test.ts).
- Keyboard state machine tests against the real CommentPopover in
  happy-dom (bare trigger, insertion, composition, Escape, highlight
  bounding, opt-out inertness), added to the CI DOM step.
- The insertion path dismisses the trigger start so the menu close is
  ordering-safe against React's select-plugin re-reading a stale caret.

* feat(comments): redesign the skill reference menu (bare triggers, no preselection, highlighted tokens)

Per maintainer direction, reversing the earlier bare-trigger opt-out
deliberately: typing a bare / or $ at the start of a word now opens the
full skill catalog immediately, and the safety story moves from the
trigger to the menu itself.

No preselection (the load-bearing rule): the menu opens with NO row
active, and while nothing is active every key behaves exactly as if the
menu were closed. "This costs $" + Enter is a newline; "cd /" + Tab
leaves the field (the proven regression that must never return). A row
activates only via ArrowDown/ArrowUp (Down from none lands on the first
row, Up on the last); only then do Enter/Tab insert. Pointer hover never
activates a row, because the menu floats exactly where the mouse rests
over the composer; a click inserts directly and never arms Enter.
Continuing to type re-filters and disarms any active row. Escape clears
the active row and dismisses when the user engaged (query typed or row
active); an unengaged bare-trigger menu passes Escape through so closing
the composer still costs one press.

Menu redesign to the reference look: icon, bold name, dimmed inline
description with ellipsis, right-aligned source column (Agents / Claude
/ Codex from the discovery roots), rounded generously padded rows, and a
subtle active-row background; human-only rows stay dimmed with their
badge and the warning now shows while such a row is ACTIVE.

Inserted references render highlighted in the composer via a mirrored
aria-hidden overlay behind a transparent-text textarea (identical font,
padding and wrapping metrics; scroll synced; tokens change color and
background only, drawn from the --primary theme token so every palette
works in light and dark). The caret keeps --foreground, selection uses a
translucent primary wash, and IME composition temporarily restores
native textarea text so composition underlines render normally.
skillReferences={false} still renders the plain pre-feature textarea.

Also, per review:
- extraction: dropped the over-broad shell-redirect exclusion (false
  negatives on prose like "use /animate <- this one"; the motivating
  case stays covered by the reserved-path rule)
- frontmatter: an unterminated frontmatter block now fails CLOSED on
  disable-model-invocation even in complete (untruncated) files
- the reserved-path / to $ insertion switch stays: extraction still
  reads /run as a path, and the new token highlight makes the switch
  self-explanatory (an unhighlighted insert would look broken)

The composition guard, transport seam, catalog generation counter,
enabled gating, and export rules are unchanged and re-covered by the
rewritten DOM test matrix.

* fix(comments): give the skill reference menu adaptive, viewport-clamped placement

The menu rendered bottom-full with a fixed max-h-64: always upward, up to
256px, with no viewport awareness. With the comment popover near the top of
the viewport (annotating near the top of a document), typing a trigger ran
the menu off the top of the screen with its upper rows unreachable.

Placement now mirrors the popover's own computePosition idiom: measure the
space above and below the composer wrapper against window.innerHeight,
prefer above (the shipped direction; keeps the action row and human-only
notice visible), flip below when the list fits below but not above, and when
neither side fits pick the roomier side. The list's max height is clamped to
the available space (still capped at the former 256px), so the menu never
extends past a viewport edge. Recomputes on every commit (drag moves,
popover flips, filtering changing the item count, warning-footer toggles)
plus capture-phase scroll and resize listeners, matching the popover's
tracking. Visual design of the menu and rows is unchanged.

* feat(comments): inject human-only skill instructions into exported feedback

A human-only skill (disable-model-invocation: true) referenced in a review
comment used to export as a dead name the agent could do nothing with. A
human referencing a human-only skill IS the human invocation, so the export
now injects the skill's SKILL.md body verbatim (frontmatter stripped) inside
clearly delimited BEGIN/END SKILL INSTRUCTIONS markers, with the absolute
skill directory and the resolve-relative-paths pointer so references/,
scripts/, and assets/ stay actionable. Model-invocable skills keep exporting
as names the agent can invoke itself.

Transport is lazy: a new GET /api/skills/content?name= endpoint (Bun and Pi)
serves one discovered skill's body, capped at 20k chars with an explicit
truncation notice pointing at the file; the client fetches contents only for
the human-only skills actually referenced, keyed off comment state, and the
catalog now carries each skill's absolute dir so every failure path (deleted
skill, unreadable file, race with submit) degrades to naming the skill plus
its directory. Names are matched against discovery only and never used as
paths, so traversal cannot escape the skill roots. A per-export dedupe
injects each skill once even when several comments reference it, and
GLOBAL_COMMENT annotations run through the same block.

The referenced-skills header now says the reviewer is asking for the
invocation, and the human-only menu footer and composer notice explain that
the skill's instructions will be included with the feedback instead of
warning that the reference will not work.

* polish(comments): quiet, progressive human-only skill treatment

The human-only surfaces shipped with too much emphasis: a dimmed row plus
a bordered uppercase badge, an amber warning footer, and a persistent
amber notice in the composer after insertion. Human-only is a property of
a skill, not an error state, so the treatment is now quiet and
progressively disclosed:

- Menu rows render at full strength with a small muted 'human-only' pill
  (bg-muted / muted-foreground tokens; no border, no dimming).
- The plain-language explanation (a model cannot invoke it, so its
  instructions will be included with your feedback) appears as a muted
  footer only while a human-only row is active (keyboard) or hovered
  (pointer). Hover disclosure is purely visual state local to the menu;
  it never touches activeIndex, so the no-preselection invariant and the
  hover-never-arms-Enter rule are unchanged and re-asserted by a new test.
- When not disclosed, the same sentence stays in the DOM sr-only and
  human-only rows point at it with aria-describedby, so the state reaches
  assistive tech as text rather than as a purely visual badge (this does
  not attempt the #1233 combobox semantics, and does not worsen them).
- After insertion, the highlighted token itself carries the quiet inline
  marker (a dotted primary underline; text-decoration cannot move glyphs,
  so overlay alignment is untouched) and the standing amber notice is
  replaced by a native <details> disclosure: a single muted 'Includes
  skill instructions' summary line that expands to the full accurate
  sentence, operable by pointer, keyboard, and AT alike.

No amber remains; every color is a theme token (muted, muted-foreground,
border, primary, ring), so the treatment follows every palette in light
and dark. Copy is unchanged where it was accurate. Behavior is unchanged:
human-only skills stay selectable and injection still happens.

* fix(comments): harden human-only skill injection per adversarial review

Three findings on the injection path, each with tests that fail pre-fix:

1. Marker forgery: an injected SKILL.md body containing our own
   `--- BEGIN/END SKILL INSTRUCTIONS ---` markers (or an
   `[Instructions truncated:` notice) could close the block early — making
   everything after it read as the reviewer's own words — forge a block for
   a skill nobody referenced, or forge a truncation notice pointing at an
   attacker-chosen path. Body lines matching the structural marker forms
   (leading-whitespace and case variants included) are now visibly
   neutralized before injection: kept verbatim but prefixed, never silently
   deleted (neutralizeSkillMarkerLines).

2. Forged human invocation: POST /api/external-annotations is
   unauthenticated on localhost, so any local process could submit a
   comment referencing a human-only skill and cause its instructions to be
   injected "at the reviewer's request". Annotations carrying a `source`
   now still LIST their skill references but never cause verbatim
   injection — human-only references fall back to naming the skill plus
   its directory, with an honest reason. The content-prime effect skips
   external texts for the same reason. A human referencing a human-only
   skill IS the human invocation; a tool is not.

3. Unbounded read: readReferenceSkillContent read the whole SKILL.md
   before slicing to the 20k cap, so an unauthenticated no-cors fetch loop
   could balloon RSS by file size per request (measured +64.4MB for a 64MB
   file). It now uses the same bounded readFileHead as the catalog,
   reading only frontmatter allowance + 4 bytes per capped char + slack;
   truncation detection is unchanged for any file whose frontmatter fits
   the catalog bound, and frontmatter that overflows the read falls back
   to null rather than serving raw YAML. Measured: 12 reads of a 64MB
   SKILL.md now cost +5.1MB total.

Also: the fast-fail guard no longer rejects legitimately discovered names —
`name.includes("..")` 404'd a real `v1..2` skill dir forever (and `\` is
legal in POSIX names) while defending nothing, since the name is only ever
matched against discovery output and never joined into a path. It now
rejects exactly the names that can never be a readdir entry: empty, `.`,
`..`.
2026-08-07 09:50:42 -07:00
Michael Ramos 57b0358265 test(review): actually restore @pierre/diffs after the lifecycle mocks (#1230)
The afterAll restore in AllFilesCodeView.lifecycle.test.tsx captured the
module namespace object with `await import(...)`. A namespace is a live view
of the module record and bun's `mock.module` rewrites that record in place,
so the captured handle became the stub the moment the mocks were installed
and handing it back re-installed the stubs as themselves.

The stubs therefore stayed live for the rest of the process. Whenever bun's
file walk put this file before discardRestoreRender.test.tsx, that test's
`processFile(...)` returned null and it failed on its first assertion.

Spread-snapshot the exports at capture time, the idiom configure.test.ts
already documents, and add an in-file guard so a future revert to the bare
import fails here instead of somewhere downstream. Also correct the claim
that the relative-path sibling mocks cannot escape this file: `mock.module`
resolves them to absolute module paths and they leak like any other.
2026-08-06 20:22:17 -07:00
Michael Ramos 9dc816959c test(install): give the PowerShell scanner tests room for pwsh cold start (#1227)
The first pwsh spawn in the process pays assembly-load and JIT cost that
exceeds bun's 5s default on a loaded CI runner, so whichever scanner test
ran first failed with a timeout. It blocked two unrelated PRs today. Warm
spawns finish in ~300ms, so the six tests now carry an explicit 60s budget
instead of racing the default.
2026-08-06 18:43:22 -07:00
Ivan Giuliani b075677033 fix(review): show the full branch name when hovering the worktree switcher (#1223)
On a long branch name you can't tell which branch you're on from the
worktree switcher button because it truncates its label to keep the
toolbar compact. The trigger already had a native title, but it never
showed the branch. On the current checkout it just said "Main
repository", while on a worktree it showed the path rather than the
branch, and the dropdown rows had no title at all.

The full name is now always one hover away because we put the full
branch label into the button's title, add the worktree path when one is
active, and give each dropdown row a title for both its name and its
path.
2026-08-06 18:39:06 -07:00
Michael Ramos b69742c3bf feat: add PLANNOTATOR_URL_HOST display-only override for advertised URLs (#1225)
* feat: add PLANNOTATOR_URL_HOST display-only override for advertised URLs

Remote mode binds 0.0.0.0 but every advertised URL hardcoded
http://localhost:<port>, so a session opened from another device (e.g. a
phone on the same tailnet) got an unopenable link (#657).

- resolveUrlHost() in packages/shared/config.ts: PLANNOTATOR_URL_HOST env
  var over config.json urlHost, validated host-only (bare hostname, IPv4,
  bracketed IPv6); invalid values warn once and fall back to localhost.
- buildAdvertisedUrl(port) in packages/server/remote.ts and its Pi mirror
  in apps/pi-extension/server/network.ts; all 7 construction sites use it.
- Strictly display-only: binding stays governed by PLANNOTATOR_REMOTE, and
  agent-review jobs get a pinned http://127.0.0.1:<port> API URL.
- Remote-ready copy says "open on your device" when the host is
  overridden; local sessions with an override warn it is unreachable.
- Tests for validation, precedence, and URL composition in both runtimes;
  docs in CLAUDE.md and the marketing site.

* fix(review): ignore urlHost in local sessions, harden warning output

Review follow-ups on #1225:
- Local (loopback-bound) sessions no longer honor the advertised-host
  override: honoring it auto-opened http://<host>:<port> against a server
  nothing was listening on, openBrowser still reported success, and the
  agent blocked on waitForDecision. Local sessions now advertise and open
  localhost, warning once that PLANNOTATOR_REMOTE=1 is required.
- The invalid-host warning JSON-encodes the echoed value so an embedded
  newline cannot forge extra stderr lines (hosts surface session-ready
  lines as clickable links); warn-once is now per value.
- Docs: local-session behavior reworded, the empty-env-suppresses-config
  semantic documented, secure-context note generalized.
2026-08-06 18:29:11 -07:00
Michael Ramos 548497e6f1 fix(annotate): hide the collapsed sidebar tab flags with "Hide tools" (#1226)
The header's "Hide tools" toggle dropped the annotation toolstrip and the
HtmlViewer action cluster, but the collapsed sidebar tab flags kept
protruding from the left edge, so a rendered HTML page never actually got
the whole viewport.

Fold both overlay guards into one derived `htmlChromeHidden` and apply it
to the SidebarTabs render. The flags unmount rather than fade, so nothing
focusable stays in the tab order and no click target sits over the page.

The toggle only exists on HTML surfaces, and both restore paths stay
visible: "Show tools" lives in the header (never hidden), and Mod+B still
opens the sidebar directly.
2026-08-06 18:29:07 -07:00
Michael Ramos bbae458e5a chore: bump version to 0.26.2 v0.26.2 2026-08-06 00:50:56 -07:00
Michael Ramos 75e8b78cc7 fix(review): stop stubbing files whose worktree content the size probe cannot find (#1220)
The oversized preflight in buildBoundedTrackedDiff mapped every object the
cat-file batch could not size to infinity. `missing` is routine: for
tree-vs-worktree diffs git hashes the WORKING-TREE content of any path pulled
into rename/copy detection and prints that hash in --raw output without ever
writing the blob, and partial clones report it for unfetched blobs. Those
files were excluded by pathspec and replaced with a contents-free binary stub,
so a renamed-and-edited file rendered as a silently empty card and
/api/file-content refused it as binary.

Missing now means unknown, not oversized, and an unreadable new side is bounded
by the working-tree file's stat size instead. Every rendered diff stays bounded
git-side by core.bigFileThreshold (#1205), genuinely oversized files still stub
via real probe sizes plus the stat door, and a blob git truly cannot read now
fails loudly through assertGitSuccess instead of blanking a file.

Client side, a chunk with a binary marker and no hunks now renders an explicit
placeholder in both the all-files view and the single-file viewer, so an empty
card can never again pass for "no changes here".

AI-assisted.
2026-08-06 00:47:19 -07:00
Michael Ramos 7ba4e3b3e4 fix(review): mint content-derived diff cache keys so single-file tabs render fully (#1219)
* fix(review): mint content-derived diff cache keys so single-file tabs render fully

Single-file diff tabs have not rendered their full-content diff since
v0.26.0: the expansion gap bars show no chevrons and clicking them does
nothing, at every file size.

@pierre/diffs 1.3.2 (the 1.2.8 -> 1.3.2 bump, upstream "Fix diff rerender
in edit mode (#878)") added name-based cacheKey defaulting in
FileDiff.render: an unset `fileDiff.cacheKey` becomes the file's name.
`areDiffTargetsEqual` — the only identity check its render and highlight
caches make — compares nothing but that key.

DiffViewer renders each file twice on one surviving FileDiff instance
(key={filePath}): first the PARTIAL diff from getSingularPatch, then the
AUGMENTED full-content diff from processFile once /api/file-content
lands. Neither set a cacheKey, so both defaulted to the filename and
Pierre served the stale partial render forever. Only the augmented diff
is expandable, hence the dead gap bars.

Both diffs now mint content-derived keys (`<path>#<hash>` and
`<path>#full#<hash>`), matching how AllFilesCodeView already keys its
items — which is why the all-files view was never affected. The hash
(not patch.length) matters because Pierre's worker highlight cache is a
singleton that outlives remounts. The partial diff needs its own key too:
with key={filePath} the instance also survives diff-type and base
switches, where a same-named new patch would otherwise hit the same
name-keyed stale cache.

hashString moves from AllFilesCodeView to utils/hashString.ts so both
surfaces mint keys the same way.

Covered by a new DOM test that mounts DiffViewer against the real
@pierre/diffs renderer, holds the /api/file-content response until the
non-expandable partial baseline is asserted, then requires the expansion
affordances to reach the pixels. It fails against the unfixed tree.

* fix(review): explain why an oversized file's card has no diff

Files over the 5 MB review limit are replaced by a contents-free stub
(buildOversizedTrackedStub, plus the untracked equivalent), which renders
as a header-only card with no counts and no explanation. Users read that
as a broken diff.

The stub now carries an explicit marker line in its extended header
(OVERSIZED_REVIEW_STUB_MARKER). A marker rather than a client heuristic
because the only other signal, `Binary files ... differ`, is exactly what
a genuine binary file emits, so a heuristic would put a false size-cap
explanation on every image in the diff. The marker lives in
shared/diff-paths so the browser bundle can detect it without pulling in
the node-facing review core; both server runtimes pick it up from
review-core, which vendor.sh already copies to Pi. Git ignores unknown
extended-header lines and @pierre/diffs parses the stub identically with
or without it, so nothing else moves. Which files get stubbed is
unchanged.

Both review surfaces now render one line under the file header saying the
file is over the limit and only a stub is shown.

* test(review): make the diff-swap proof machine independent, not stopwatch based

CI failed two tests that pass locally. Both were timing races, neither was
an app bug.

1. DiffViewer.fullContentSwap: the swap assertion carried a 15s internal
   wall-clock budget, which a cold, contended CI runner blows and a warm
   laptop clears. Two changes, both aimed at the clock rather than the
   symptom:

   - The waits are now budgeted in SCHEDULER TURNS, not milliseconds. A
     slower box spends longer inside each turn but needs no more of them,
     so the budget never has to be retuned for CI hardware.
   - Pierre's shared Shiki highlighter is preloaded before mounting. It is
     a module singleton, and building it was the entire multi-second cost
     the old budget was accidentally measuring; warming it moves that work
     into an unbounded await OUTSIDE the observed window. Disposed in
     afterAll, because packages/ui/utils/codeHighlight.test.ts asserts the
     pre-attachment behaviour of that same singleton.

   Verified against an artificially stalled clock: forcing 20s of dead time
   into every wait (41s total, far past the old 15s budget) still passes,
   and with the cacheKey fix removed it still fails on the assertion (not
   as an opaque timeout) in ~12s. A 20-turn budget with the preload removed
   and every core saturated also passed 10/10, so 400 turns is a wide
   margin rather than a guess.

2. App.archiveReadOnly compared the fenced block's innerHTML before and
   after a click. Since #1218, applyHighlight writes plain text first and
   swaps in Shiki markup when the grammar attaches, so that MARKUP changes
   on its own schedule and the assertion was racing the swap. The test is
   checking that the click opened no mutation entry point, which textContent
   plus the absence of an annotation <mark> says exactly, and which no
   highlight swap can perturb. Latent on main; the branch's run happened to
   lose the race.

Also fixed while confirming the above: codeHighlight.test.ts asserted a
GLOBAL precondition ("no grammar attached yet") that any earlier file
attaching a typescript fence invalidates, so
`DOM_TESTS=1 bun test packages/ui packages/editor` failed by file order
alone. It now resets the attachment cache through the existing
__resetCodeHighlightCacheForTests seam and asserts the contract instead of
the run order. Not currently reachable from CI (that file is not in the DOM
list), but one list edit away.

* test(review): drop the highlighter preload, harden the swap proof, report why a paint is missing

The preload added in the previous commit made CI strictly worse, so it is
gone. Before it, CI's partial diff painted and only the swap was missing;
with it, CI never painted at all. It was an optimization for a theory the
evidence has since killed, and it mutated a process-wide singleton to buy
it, so it is not worth keeping while the real failure is unexplained. The
afterAll dispose that existed only to undo the preload goes with it.

What the CI log actually shows:

  - The "WorkerPoolManager: operation canceled because the pool terminated"
    error is inside discardRestoreRender.test.tsx's own group, ~0.3s BEFORE
    this file's group opens. It is that file's provider unmounting and
    terminating the pool singleton it created: end-of-file teardown, the
    same benign noise documented on #1209. It also prints on every local
    run, where the whole list passes. It is not a mid-test terminator, and
    nothing in this file uses the worker pool (no WorkerPoolContextProvider
    is mounted, so useWorkerPool() is undefined and rendering takes the
    main-thread path).
  - This file's group prints NOTHING for its whole 10.3s: no console.warn
    from the stale-content guard, no error. Pierre simply painted nothing.

Not reproducible locally: the exact DOM list from test.yml, one bun
process, forward and reverse order, 13 runs with every core saturated, all
green. So the remaining difference is the environment, which cannot be
reasoned out from here. Three changes make the next CI run answer it
instead of costing another guess:

  - renderDiagnostics() dumps what Pierre actually painted (container /
    separator / chevron / line-number counts plus a markup fragment) when a
    wait gives up. Prints only on failure, so it is worth keeping.
  - The precondition is asserted rather than assumed: the REAL
    getSingularPatch and processFile must produce partial-then-full on
    these fixtures. Bun's mock.module is process global and an earlier file
    in this very list mocks '@pierre/diffs', so a leaked mock now fails in
    milliseconds with a clear message instead of as a render that never
    arrives.
  - The first paint is now REPORTED, not asserted. The verdict belongs to
    the swap; gating on the partial paint let a slow or absent first paint
    mask the result the test exists for. Removing the cacheKey fix still
    fails it (verified), because that tree paints no chevrons at any point.

Also fixed a real trap in the fixture: the hunk header said @@ -61 while
its context lines start at line 59 of both contents. Pierre realigns a
misaligned header rather than rejecting it, so it was silently tolerated.

* test(review): stop a leaked module mock from silently unrendering the diff tests

Root cause, and it was never a timing problem.

AllFilesCodeView.lifecycle.test.tsx calls
`mock.module('@pierre/diffs', ...)` with a hunk-less `getSingularPatch` and
`processFile: () => null`. Bun's module mocks are process global and are not
unwound at file boundaries, and that file sits immediately before
DiffViewer.fullContentSwap.test.tsx in the DOM step's list. On the Linux
runner the stub reached this file; on macOS it did not, which is why 13
local runs of the exact list, both orders, cores saturated, stayed green.

It explains both CI symptoms exactly, including the one that looked like a
contradiction: `processFile: () => null` means the augmented diff never
exists, so no chevrons ever (the failure before the preload); the stub
`getSingularPatch` has `hunks: []`, so nothing paints at all (the failure
after it). The "WorkerPoolManager: operation canceled because the pool
terminated" line was a red herring throughout: it is inside
discardRestoreRender's own group, ~0.3s BEFORE this file's group opens, is
that file's provider unmounting the pool it created, and prints on every
local run too.

The precondition assertion added in the previous commit is what proved it,
turning a 10.3s mystery into a 0.45ms verdict:

  228 |       expect(expected?.isPartial).toBe(false);
  error: expect(received).toBe(expected)
  Expected: false
  Received: undefined

Fixed at both ends:

  - Source: the mocking file now captures the real modules before it stubs
    them and restores both library specifiers in afterAll, so no later file
    in any run inherits its stubs. This fixes the class for every future
    DOM test that needs the real renderer, which was the actual leak.
  - Consumer: the two tests that render against the real @pierre/diffs get
    their own CI step, the same isolation (and for the same kind of reason)
    this workflow already gives useFileBrowser.test.tsx. They are removed
    from the shared list so that step is their single source of truth. The
    restore above should make this unnecessary; it is not something to bet
    a green build on from a machine that cannot reproduce the platform
    behaviour.

Verified with a CI-faithful harness: one bun process per step, the exact
lists from test.yml, isolated + shared-forward + shared-reverse, 10
iterations with every core saturated, then 6 more after the final split.
All green, plus the full suite and typecheck.

* docs(test): point the diff-renderer DOM tests at the CI step that actually runs them
2026-08-06 00:31:25 -07:00
Michael Ramos 2dd5b7b25d docs: self-hosting page no longer claims a bundled Highlight.js (#1221)
highlight.js was removed in #1218; the portal bundles Shiki via the diff
renderer. Caught by the v0.26.2 dependency audit.
2026-08-05 23:42:10 -07:00
Michael Ramos c08b188812 perf(ui): single Shiki highlighter, palette-matched code blocks, drop highlight.js (#1218)
* perf(build): stub out the dead Oniguruma WASM in every bundle

@pierre/diffs picks its Shiki engine with a runtime ternary:

    engine: preferredHighlighter === "shiki-wasm"
      ? createOnigurumaEngine(import("shiki/wasm"))
      : createJavaScriptRegexEngine()

Plannotator pins `preferredHighlighter: 'shiki-js'` (and Pierre's own
default is 'shiki-js'), so the Oniguruma branch never executes. Because
the choice is a runtime ternary, bundlers keep the `import("shiki/wasm")`
edge anyway and inline `@shikijs/engine-oniguruma/wasm-inlined`, a
~622 KB base64 blob, into the single-file HTML builds. The review app
paid for it twice: once on the main thread (via
`highlighter/shared_highlighter.js`) and once inside the `?worker&inline`
Pierre worker.

Alias `shiki/wasm` to a stub that throws if it is ever reached. Wired via
`resolve.alias` rather than a plugin because `resolve.alias` is shared
with Vite's worker build and `plugins` are not.

Highlighting output is unchanged: the JS regex engine was already the one
doing the work. Opting back into 'shiki-wasm' now fails loudly instead of
silently costing every user a megabyte of dead bytes.

    apps/review/dist/index.html  19,424,646 -> 18,180,545  (-1,244,101 raw / -463,348 gzip)
    apps/hook/dist/index.html    23,032,467 -> 22,410,416    (-622,051 raw / -233,485 gzip)

* perf(ui): consolidate code highlighting onto Shiki, drop highlight.js

The app shipped two highlighters. Shiki already tokenised the code-review
diff pane (via @pierre/diffs, JavaScript regex engine); highlight.js
separately coloured markdown fences and review suggestion snippets at
~982 KB minified for a full build of ~190 grammars. That second
highlighter is now gone.

Every call site moves onto `packages/ui/utils/codeHighlight.ts`, a thin
wrapper over Pierre's SHARED Shiki instance:

  CodeBlock, Viewer, PlanCleanDiffView   markdown fences
  InlineMarkdown                          code-file hover preview
  HighlightedCode                         review suggestion snippets

Reusing Pierre's instance rather than standing up a second fine-grained
one is deliberate. Pierre imports Shiki's full bundle, so every grammar
and theme is ALREADY inlined in the single-file builds: a separate
highlighter with a curated language list would have duplicated a subset
of bytes that are already there. Sharing costs nothing, gives every
language Shiki bundles instead of a shortlist, and — the point of the
change — guarantees fences resolve the exact same theme the diff pane
resolves.

Theming. `SHIKI_THEME_MAP` / `resolveSyntaxTheme` move from
`packages/review-editor/hooks/usePierreTheme.ts` to
`packages/ui/utils/syntaxTheme.ts`; usePierreTheme re-exports them, so
the review editor's imports are unchanged. `useFenceTheme()` feeds the
components and re-highlights on palette or mode change. Code blocks now
follow the active palette across all ~52 themes in both light and dark,
instead of always rendering github-dark and relying on hand-written
`.hljs-*` override stacks to stay legible. Those stacks are deleted:
`packages/editor/index.css`'s light-mode token palette, and
`colorblind.css`'s hand-tuned tokens which existed to APPROXIMATE
@pierre/theme's protanopia-deuteranopia themes that are now simply used.

Behaviour held fixed:

  - Language-less fences stay plain text (#1212). No auto-detection
    anywhere, including the hover preview, which previously called
    `hljs.highlightAuto`. `HighlightedCode` derives its language from
    the caller's file path; an unknown extension renders plain.
  - `applyHighlight(el, ...)` keeps the imperative `hljs.highlightElement`
    DOM contract the annotation layer reaches into, and writes plain text
    at final size first so async highlighting causes no layout shift.
    Already-attached grammars highlight synchronously — no flicker on
    cached highlights.
  - It also verifies the rendered text is byte-identical to the source
    and falls back to plain otherwise, because annotations address code
    blocks by text offset.
  - `@plannotator/ui`'s public API is unchanged: the highlighter is a
    module-level default like the package's other seams, no new props.

The `hljs` class on fenced `<code>` becomes `pn-code` (it is a
structural hook for blockTargeting, vim navigation and print.css, and it
named a library we no longer ship). `language-*` stays.

    apps/review/dist/index.html  18,180,545 -> 17,270,889  (-909,656 raw / -291,921 gzip)
    apps/hook/dist/index.html    22,410,416 -> 21,704,434  (-705,982 raw / -238,096 gzip)

Verified the diff pane is untouched: the rendered Pierre shadow-DOM
markup is byte-for-byte identical between an origin/main build and this
one (SHA-256 aa1ee88a…).

* fix(ui): strip stray NUL bytes from the code-highlight source

Two U+0000 bytes slipped into comments in the previous commit, which made
git treat the file as binary. Replaced with spaces; no behaviour change.

* fix(ui): keep code-block annotation marks across highlight swaps

Fenced code is annotated by hand: one `<mark data-bind-id>` inside the
`<code>` element, which `applyHighlight` also owns. Every highlight swap
(palette change, dark/light toggle, or the first async grammar attach
after load) replaces that element's children, so the mark was silently
wiped and nothing put it back. Annotation state, the sidebar panel and
exports were unaffected; the loss was purely visual, and deterministic.

`applyHighlight` now publishes every write through `onCodeHighlightSwap`,
synchronously, immediately after it. `Viewer` subscribes and re-paints the
fence's mark, so a swapped block ends up with BOTH the new theme's tokens
and its annotation. The shared painter (`paintCodeBlockMark`) moves the
token spans into the mark instead of flattening them to text, so creating
an annotation no longer costs a block its colours either.

Being driven by the swap also fixes the cousin race by ordering rather
than timing: share/draft restore runs on a timer after load, and on a slow
machine the first async swap could land after it and wipe the restored
marks per block. A restore that painted before the swap is now
re-established in the same task the swap ran in, and one that runs after
finds the mark already there.

Removal tombstones the id before re-highlighting, because the host drops
the annotation from state a tick later — without it the swap listener
would paint the just-removed annotation back in, and a fence carrying a
second annotation would end up bare.

Also closes the named gap in the WASM coverage: entry-assets only grepped
source, so a future @pierre/diffs bump could reintroduce the inlined blob
through a different import specifier unnoticed. It now greps the built
`apps/{review,hook}/dist/index.html` for the base64 WASM magic, skipping
on an unbuilt checkout and running for real in the CI job that builds the
bundles.
2026-08-05 21:54:40 -07:00
Michael Ramos 2d65c65596 feat(ui): pair a light theme and a dark theme, switched by mode (#1217)
* feat(ui): pair a light theme and a dark theme, switched by mode

ThemeProvider stored one palette plus a mode, so picking a dark-only
palette pinned the mode and greyed out the Light/System buttons. Store a
pair instead: { mode, light, dark }, resolved as pair[preferredMode], so
System flips between the two choices as the OS scheme changes.

The Settings Theme tab now assigns one half at a time. A Light/Dark
switch decides which half the grid is filling, the grid lists only the
palettes that can render that half (from the registry's modeSupport), and
a summary line names both halves with each side clickable. Every mode
button is permanently enabled: a dark-only palette simply never occupies
the light slot, so no mode coercion is left to do.

The pair round-trips through the SETTINGS registry to the `theme` key in
~/.plannotator/config.json the way diffOptions does. A user upgrading
seeds both halves from their stored single palette, and the legacy
plannotator-color-theme key keeps tracking the active palette so a
downgrade never lands on an unstyled first frame.

Addresses part 1 of #1211.

* fix(ui): make the theme pair seed local, and keep the legacy API non-destructive

Review of #1217 found a data-loss path and three published-API regressions.

Seeding: ThemeProvider handed its resolved pair to the config store through
set(), which queues a debounced POST. configStore.init() applies the server
config but never cancelled that queued write, so a single cookie-less visit
(fresh profile, incognito, cleared cookies) flushed a default pair to
~/.plannotator/config.json AFTER the real one had arrived, and the next
session restored those defaults over the user's cookies. The provider now
uses a new configStore.seed(): memory plus cookie, never the server, and
never over a value init() already applied. init() additionally retracts
queued writes for the leaves the server just spoke for, which closes the
same race for every server-synced setting rather than this one key.

Deprecated APIs: isThemeModeAvailable() and normalizeThemeMode() are back as
one-line wrappers with @deprecated notes, since packages/ui exports utils/*.

setColorTheme: assigns exactly one half and nothing else. A both-mode palette
goes to the half on screen instead of clobbering both; a mode-restricted one
goes to its half without yanking a System user to an explicit mode (render
time already resolves that). It persists through configStore.setLocal(), so
it stays cookie-only as it was before the pair, unless a host installed its
own serverSync transport.

storageKey / colorThemeStorageKey are honored on the read path, so a host's
stored pre-pair preference is migrated rather than discarded. The two halves
have no pre-pair equivalent and stay on fixed keys, documented on the props.

Tests: a fresh-mount case that pins zero POSTs (the previous helper pre-seeded
cookies, which is why this was invisible), a case that pins a real choice
still reaching config.json, direct setColorTheme cases for all three
semantics, a host-storage-keys migration case, and configStore seed/retract
unit tests. All of them fail against the code they replace.
2026-08-05 21:51:55 -07:00
Zeke Sikelianos c072a16ca6 fix(ui): render language-less code blocks as plain text (#1212)
Bare fenced code blocks (no language tag) were passed to
hljs.highlightElement() unconditionally, so highlight.js auto-detected
a language and colored ordinary words. Skip highlighting when the block
has no language so it renders as plain monospaced text.

Fixes #1210
2026-08-05 19:45:10 -07:00
Michael Ramos 50a54c872b chore: bump version to 0.26.1 v0.26.1 2026-08-05 11:39:02 -07:00
Michael Ramos b1745683fd fix(review): fall back across GitButler JSON flag syntaxes (but 0.22.0) (#1216)
GitButler 0.22.0 removed the global --format flag in favor of --json
(gitbutlerapp/gitbutler#15026), so `but --format json status` now dies
with clap's unexpected-argument error and GitButler review sessions fail
to start. 0.21.x accepts only --format json (gitbutlerapp/gitbutler#14061),
so neither spelling works everywhere.

Keep --format json as the primary invocation and, only when it fails with
clap's narrow unexpected-argument rejection for the exact flag we passed,
retry once with the other spelling. Real status failures never retry and
keep failing loudly per the module's contract-error philosophy. The
accepted spelling is remembered per runtime so 0.22.0 installs pay the
failed probe once. Error strings now name the syntax actually used.

Closes #1215
2026-08-05 11:37:07 -07:00
Michael Ramos 8396eeb787 ci: auto-sync install scripts to their dedicated S3 bucket (#1214)
plannotator.ai serves /install.sh, /install.ps1, and /install.cmd from the
plannotator-install-scripts bucket, not the marketing bucket the deploy
workflow syncs. Nothing in CI wrote to that bucket, so the served scripts
went stale between hand syncs: v0.26.0 shipped while the site still served
the July 31 installer, missing --skip-skills, the checkout guard, and the
PowerShell < 7.2 fix. This adds a deploy-install-scripts job triggered by
changes to the three scripts (plus a workflow_dispatch target), uploading
them with content types and invalidating exactly those paths.
2026-08-05 10:44:06 -07:00
Michael Ramos f555168deb chore: bump version to 0.26.0 v0.26.0 2026-08-05 09:19:51 -07:00
Michael Ramos 5fbff4bff3 fix(review): make the Edit Mode opt-in an explicit switch, not a primary CTA (#1213)
The announcement footer's primary Turn it on button read as a generic
continue and invited blind clicks: opting into an experimental feature
was one reflexive Enter away. The decision is now an explicit Enable
Edit Mode switch (defaulting off, with the shared TextShimmer label
treatment) beside a single neutral Done button that applies whatever
the switch says. Pressing Done untouched is a plain dismissal, and the
focused default action is asserted consent-neutral in tests. Announcement
cookie version bumped so the revision re-shows (pre-release, no users
have the old cookie).
2026-08-05 09:07:23 -07:00
Michael Ramos a4816e7208 feat(review): announce Edit Mode with an opt-in welcome dialog (#1207)
* feat(review): announce Edit Mode with an opt-in welcome dialog

One-time cookie-gated announcement for the experimental Edit Code to
Suggest feature, slotted last in the first-run dialog chain (guide intro,
look-and-feel, review setup, edit mode) so the chain dialogs never stack.
Turn it on persists the real editSuggestions setting; Keep it off just
marks the announcement seen. The demo panel carries a video slot that the
single-file build inlines as a data URI; until the recording lands it
renders a static placeholder behind one constant.

* feat(review): bundle the Edit Mode demo recording into the announcement

Wires the quality-verified screen capture (VP9 webm, ~563KB, 1100x700,
18.8s) plus its poster still through the editModeDemoMedia swap point.
The single-file build inlines both as data URIs (verified in the built
bundle). The video slot autoplays muted, loops, plays inline, and keeps
the recording's 11:7 aspect so nothing is cropped. The static placeholder
stays as a test seam and safety net, with its coming-soon caption removed.
2026-08-05 08:50:56 -07:00
Michael Ramos 7aff70281f fix(review): repaint pristine diff immediately on edit-mode Discard (#1209)
writeRestore's combined item write leaves upstream's highlighted render
cache serving the stale edited content; clear the live instance's render
cache and rerender so the pristine diff paints immediately (plaintext
first, then the normal async highlight). Covers Discard, Suggest,
finishIfEditing, and the deferred external-teardown restore.

Adds a DOM-gated integration test driving the real @pierre/diffs
CodeView + EditProvider + worker pool through startEdit -> real
Editor.applyEdits -> cancelEdit, asserting the pristine content is
back in the shadow DOM within a bounded settle.
2026-08-05 08:50:53 -07:00
Michael Ramos 3d435184dc fix(review): keep large-diff memory bound when the object-size probe fails (#1205)
A failed `cat-file --batch-check` used to map every changed object to
infinity, replacing the ENTIRE review diff with `Binary files ... differ`
stubs and no visible error, and silently degrading the staleness
fingerprint.

The memory bound is now probe-independent: every rendered diff carries
`core.bigFileThreshold=<MAX_REVIEW_FILE_CONTENT_BYTES>` injected through
`GIT_CONFIG_*` environment variables (never `-c` argv flags, so argv stays
byte-identical), making git itself stub oversized blobs. On probe failure
blob sizes read as unknown-but-bounded and files render normally; the
stat-based exclusion door for oversized working-tree files (which git's
threshold does not cover) never depended on the probe and keeps working.
Per-object doors (missing / unparseable size) stay conservative when the
probe ran.

The probe itself gains timeoutMs + interaction:"forbid" so a hung git
cannot stall the review server. Three existing mocks that returned
non-batch-check output (and passed only because of the all-infinity bug)
now return well-formed batch-check lines.
2026-08-04 22:21:56 -07:00
Michael Ramos 9c693ae348 fix(opencode): drop the bun peerDependency that made npm download a 50MB runtime (#1204)
npm >= 7 auto-installs peer dependencies, and the npm registry package
named bun ships the full Bun binary, so every install of
@plannotator/opencode pulled a useless ~50MB second copy of Bun. Express
the runtime requirement as an informational engines field instead, which
npm never installs. Also update the stale fixture comment that referenced
the peer dependency's install weight.
2026-08-04 22:06:05 -07:00
Michael Ramos e97b703479 ci: glob the OpenCode smoke tarball instead of pinning the version (#1203)
The installed-package smoke passed a hardcoded plannotator-opencode-0.25.1.tgz
path to the fixture, so every version bump would silently break the job on the
bump commit. npm pack writes exactly one plannotator-opencode-*.tgz into
RUNNER_TEMP, so a glob resolves to the same file and survives bumps.
2026-08-04 21:51:28 -07:00
Michael Ramos 10a5104888 fix(install): repair the skills checkout guard, add --skip-skills (#1201)
* fix(install): make a failed skills checkout stop reporting success

The skills/commands checkout runs in a subshell written as
`( set -e; ... ) || checkout_failed=1`. POSIX ignores `set -e` for every
command of an AND-OR list except the last, and bash 3.2.57 (what
`curl | bash` gets on macOS), bash 5.3, dash, zsh and ksh all carry that
suppression into the subshell. The `set -e` was inert.

A failed clone therefore ran the whole block anyway, the subshell exited
with the status of its trailing `if` (0), `checkout_failed` stayed 0, and
the installer printed "YOU'RE ALL SET!" with no skills installed. It also
blamed the wrong thing, printing "Tag vX.Y.Z predates the per-agent skill
layout" when the real cause was a failed clone.

Drop the inert `set -e` and guard the four fetch steps with explicit
`|| exit 1`. Everything after the checkout stays best-effort, matching
install.cmd, which only checks git clone and lets every xcopy run
unchecked. A local cp/mkdir/rm failure must not surface as the
"network or git error" message.

Verified on bash 3.2.57 in a sandboxed HOME: the failure case now exits 1
with the fetch error and no success banner, and original vs patched
success runs produce byte-identical trees (74 paths, 35 files) and
identical logs.

* feat(install): add --skip-skills opt-out

The skills and slash commands come from a sparse `git clone` of the release
tag. There was no way to decline that fetch short of --minimal, which also
drops the sem sidecar, the agent-terminal runtime, the hooks, and every
per-agent config. Anything that installs a tag github.com cannot serve had
no option at all.

Add --skip-skills to all three installers, following the existing
--skip-codex / --skip-gemini / --skip-kiro / --skip-opencode family: CLI
flag (-SkipSkills in PowerShell), PLANNOTATOR_SKIP_SKILLS_INSTALL env var,
skipInstall.skills config key, resolved flag > env > config. It is not a
per-agent switch; it covers every scope the checkout writes (Claude,
~/.agents, OpenCode, Gemini, Kiro), the extras, and the skill-scope cleanup
sweeps. Skip means do-not-write: nothing already installed is replaced or
removed, and git stops being a hard requirement. The run reports
"Skills: skipped (<source>)" and the closing banner no longer claims the
/plannotator-* commands are ready, which is the same false-success the
checkout guard exists to prevent.

Use it in the install-script-smoke job. That job installs a synthetic
v9.9.9: the fake curl serves the freshly built binary for any URL, but the
skills clone goes to real github.com, where the tag does not and cannot
exist. That clone has always failed; it only went unnoticed while the
broken guard let the installer exit 0 anyway. The job asserts Codex hook
config, not skills, so it opts out rather than ignoring a real error. Both
run_installer call sites go through the one function definition.

Verified in an env -i sandbox on bash 3.2.57 (what `curl | bash` gets on
macOS) with a fake curl and a local stand-in remote. Flag, env var, and
config each skip and name their own source; flag beats env=0; env=0 beats
config true; an explicit "skills": false stays a veto. Without the flag,
pre-change and post-change runs produce byte-identical trees (39 entries)
and identical logs. A bad clone URL without the flag still exits 1 with the
fetch error and no success banner. A --skip-skills re-run over an existing
install leaves all 12 skill and command files byte-identical. The CI step
was reproduced locally: both run_installer calls exit 0 and every Codex
assertion still passes.

* test(install): cover --skip-skills and repoint the pinned source strings

scripts/install.test.ts asserts against exact install-script source text, so
six assertions broke when --skip-skills landed. Each is repointed at the new
string with its intent preserved, not weakened:

- The "hook/config writing happens before the git hard-fail" ordering test
  keeps proving the ordering; it just matches the gate's new conditional
  form. git being a hard requirement is now a narrower invariant (it applies
  only when the checkout actually runs), so that is asserted separately
  rather than dropped.
- The skipInstall walk assertions follow codex/gemini/kiro/opencode gaining
  a skills entry, in install.sh's `for _agent` loop and install.cmd's
  PowerShell key list.
- The three "never remove" sweep assertions follow the Codex stale-skill
  cleanup gaining its skills-opt-out arm, in all three installers.

Add nine tests covering --skip-skills itself in the same style as the
per-agent family: flag/switch parsing, PLANNOTATOR_SKIP_SKILLS_INSTALL,
skipInstall.skills, and flag > env > config precedence by textual layering,
for each installer. Each installer also gets a test that the opt-out bails
before the clone and leaves the checkout guard intact (#1201's fix must keep
failing a real fetch error), and one that the run reports honestly, never
prints the "commands are ready" banner over an empty skills dir, and
suspends the extras, the model-invocation rewrite, and the stale-stub sweeps
rather than applying them partially.

bun test scripts/: 116 pass, 6 skip, 0 fail. Full bun test: 2884 pass,
234 skip, 0 fail. bun run typecheck clean.
2026-08-04 21:49:49 -07:00
Michael Ramos d7be3406f6 fix(ci): repair the OpenCode 2 installed-package smoke (#1202)
The smoke's wait budgets were sized on a warm macOS dev box (5s to a healthy
server, 20s to plugin activation). On a cold Linux runner OpenCode 2 needs
longer to boot and has to install the packed plugin plus its whole dependency
closure through the fixture's throwaway registry first, so the job has failed
on every run since it was introduced.

Measured, same opencode2 build and the same packed tarball:

  macOS, warm caches:      healthy 0.8s, plugin activated 6.3s
  linux/amd64 container:   healthy 8.2s, plugin activated 45.3s

Raise the budgets to 120s and 300s (overridable via
PLANNOTATOR_SMOKE_HEALTH_TIMEOUT_MS / PLANNOTATOR_SMOKE_PLUGIN_TIMEOUT_MS) and
give the job a 25 minute backstop. The assertion is untouched: the smoke still
requires the plugin registry to report the plannotator plugin.

Also make a failure legible and prompt. Each poll gets a per-request timeout so
one wedged request cannot swallow the budget, waits report progress, failures
carry the elapsed time and the last HTTP status/body, and teardown escalates to
SIGKILL and force-closes the registry. The CI failure previously burned five
minutes in teardown before printing anything.
2026-08-04 20:47:45 -07:00
Michael Ramos 6b8723cc57 test(review): repair guide virtualization mock and run its tests in CI (#1200)
The AllFilesCodeView lifecycle test stubs '@pierre/diffs/react' with
mock.module, which replaces the whole specifier. #1193 added an
EditProvider import to AllFilesCodeView, but the stub was never
extended, so the file throws at import when run on its own.

The three DOM tests #1158 added for Guided Review virtualization were
also never registered in the DOM_TESTS=1 CI step, so nothing enforced
the 8-viewer mount cap and nothing surfaced the broken stub.
2026-08-04 20:44:28 -07:00
Michael Ramos 308e4ba8a5 fix(opencode): drop runtime dependency on prerelease plugin nightly (#1199)
* fix(opencode): drop runtime dependency on prerelease plugin nightly

`@opencode-ai/plugin` was a runtime dependency pinned to the exact nightly
0.0.0-next-16775, so every `npm install @plannotator/opencode` resolved a
prerelease snapshot sitting inside npm's 72h unpublish window and pulled
95MB across 101 packages (effect@4.0.0-beta.101 alone is 47MB). None of it
is executed by OpenCode 1 users.

The only runtime use was `Plugin.define`, which is an identity function
(`export function define(plugin) { return plugin; }`, verified identical
across 0.0.0-next-16775, next-16600, next-16797 and stable 1.18.13). The
import is now type-only and the plugin is a plain object literal checked
with `satisfies Plugin.Plugin`. OpenCode 2's loader validation is purely
structural (`Schema.Struct({ id: String, setup: function })` in its
supervisor), so an object literal satisfies it.

The package moves to devDependencies. Built `dist/index.js` and
`dist/embedded.js` are byte-identical to the pre-change build;
`dist/server.js` differs only by the dropped import and the two
`Plugin.define(...)` wrapper lines.

* docs(opencode): explain why the V2 logReady callback is empty

The old one-liner read as an unfinished TODO. The empty function is
correct: OpenCode 2's server-plugin Context exposes no `log` or `tui`
domain, `@opencode-ai/client` has no `tui` namespace and zero `/tui/*`
routes (checked on 0.0.0-next-16775 and next-16797), and `createV2Client`'s
`app.log` bottoms out in `console.error`, the same stderr stream
`handleServerReady` already writes to. Wiring it would print the session
URL twice in remote mode and add a stray line locally. V1 targets
`client.app.log` and `client.tui.showToast`, which are HTTP surfaces
distinct from stderr, so V1 never repeats itself. `tui.toast.show` exists
in V2 only as a subscribe-only event and on the separate
`@opencode-ai/plugin/tui` context, a different plugin kind in a different
process, so a real toast needs an upstream OpenCode API.

Comment only, no behavior change.
2026-08-04 20:38:28 -07:00
Sergiy Dybskiy 050dfcda9a feat(opencode): add OpenCode 2 plan review adapter (#1194)
* feat(opencode): add OpenCode 2 plan review adapter

* fix(opencode): harden V2 review lifecycle

* fix(opencode): address V2 review feedback

* fix(opencode): update V2 target and isolate tests

* test(opencode): assert prompt composition invariants
2026-08-04 17:56:33 -07:00
Michael Ramos 7682628db7 feat(install): Codex opt-out and credential-free attestation verification (#1197)
* feat(install): Codex opt-out and credential-free attestation verification

Implements both asks from #1178 (reported and designed by @astradevkin):

- Per-agent installer opt-outs: --skip-codex / --skip-gemini / --skip-kiro
  flags, PLANNOTATOR_SKIP_{CODEX,GEMINI,KIRO}_INSTALL env vars, and
  config.json skipInstall.{codex,gemini,kiro} keys, with flag > env >
  config precedence mirroring verifyAttestation. Detected-but-skipped is
  reported as its own honest state, never conflated with not-detected,
  and a skipped agent's home is neither written nor cleaned up.

- Credential-free provenance verification: when --verify-attestation is
  active, the Sigstore bundle is fetched from GitHub's public
  attestations endpoint (single unauthenticated attempt, no retry) and
  verified via gh attestation verify --bundle with the same
  --repo/--source-ref/--signer-workflow constraints; gh's authenticated
  fetch remains the fallback. TUF trust-root failures are reported as
  connectivity, distinct from real provenance failures; every path
  stays fail-closed.

Zero behavior change for users who do not opt in: the default install
path is unchanged (verified by sandbox-HOME parity runs against main).

* fix(install): address #1197 review round (H1 retry, M2-M7, lows)

- H1: a failed gh --bundle invocation now retries once through the exact
  authenticated path before any classification, so an older gh (unknown
  flag) or a corrupt bundle never reports as a provenance failure. Pinned
  by a functional stub-gh test; a real failure still fails again on the
  retry and aborts.
- M2: the sh config layer extracts the skipInstall object (awk, character
  indexed) before matching per-agent keys and honors explicit false as a
  veto; cmd now parses the real JSON via PowerShell like ps1. Functional
  tests cover the foreign-key collision and explicit-true cases.
- M3: sh names the real cause when the bundle path cannot run (no JSON
  extractor vs fetch vs extraction failure) and gains python3 and jq
  fallback extractors; docs state the dependency.
- M4: ps1/cmd gate the existing-integration note on plannotator content in
  hooks.json and word the skip state as what those platforms actually do
  (manual instructions suppressed).
- M5: sh bundle lives inside a private mktemp -d, one rm -rf on every exit,
  and a mktemp failure degrades to the fallback instead of aborting.
- M6: README, verifying-your-install, environment-variables, and
  installation docs updated for the credential-free path and skip flags.
- M7: ps1/cmd extraction replaced with a byte-exact string scanner (no
  ConvertFrom/ConvertTo round trip, immune to DateTime coercion), with
  PowerShell-driven unit tests over the captured real attestations
  response plus synthetic DateTime and brace-in-string controls.
- Lows: fail-closed abort pinned by a functional test; TUF beats auth in
  cmd classification (matches sh/ps1); skipped-state output mentions the
  shared ~/.agents/skills; Gemini summary is skip-aware and gains an
  honest not-detected state; --skip-opencode do-not-write switch added
  (flag, env var, config key) across all three scripts.

* fix(install): review round three (EncodedCommand fetcher, mutation-proof fail-closed test, lows)

- R1: install.cmd's attestation fetcher no longer touches disk. The
  %RANDOM%-named %TEMP% .ps1 (predictable-path code execution, the M5
  class escalated) is replaced by powershell -NoProfile -EncodedCommand
  with a base64(UTF-16LE) payload defined next to its full REM PS:
  plaintext; a test decodes the blob and asserts byte equality with the
  documented lines plus the security-relevant shape (env-var inputs,
  ordinal scan, no JSON round trip, distinct exit codes). Inputs still
  travel via env vars. Verified end to end under pwsh: the decoded blob
  fetched the real attestations response, wrote 2 bundles, gh verified
  the real v0.25.1 binary credential-free (exit 0) and rejected a wrong
  binary (exit 1).
- R2: the fail-closed test now asserts the output ENDS with 'Refusing to
  install.' - mutation-verified: with the verify-failure exit 1 deleted
  the mutant still exits 1 via an incidental mv failure, but the trailing
  mv error breaks the endsWith and the test fails; restored, it passes.
- Lows: CI guard test fails loudly when process.env.CI is set and no
  pwsh/powershell is on PATH (scanner coverage cannot silently vanish);
  scanner IndexOf calls are ordinal in ps1 and the encoded cmd variant;
  the awk skipInstall extraction requires optional-whitespace-then-colon-
  then-brace after the key (string values can no longer anchor it, with
  non-token occurrences skipped, unit-checked against escaped-embedded
  payloads); install.cmd comments warn that the fallback-reason literals
  inside parenthesized blocks must stay parenthesis-free.
2026-08-04 13:17:27 -07:00