A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI.
Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor.
Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
* feat(annotate): configurable extra markdown extensions (#1307)
Adds a config-only `markdownExtensions` key to ~/.plannotator/config.json,
e.g. { "markdownExtensions": [".livemd"] } for Livebook notebooks. A listed
extension is accepted everywhere .md is on the annotate path: CLI target
resolution, folder discovery and the file browser, /api/doc plus relative and
wiki-link navigation between sibling docs, the 2MB size cap, and per-file
version history. Listed extensions render as markdown with frontmatter
stripped, never as raw HTML, and they only widen the accepted set.
Design:
- packages/core/annotatable.ts stays browser-safe and zero-dep. Its regexes
and predicates now take an optional, defaulted-empty list of extra
extensions, plus a normalizer and regex builders.
- packages/shared/markdown-extensions.ts is the node-side seam: it reads
config.json once per process through the existing loadConfig() and threads
the normalized list into those pure functions. resolve-file re-exports the
config-aware predicates so both runtimes pick them up; the Bun server, the
Pi mirror, the OpenCode plugin and the CLI all go through them.
- The annotate /api/plan payload ships the resolved list so the renderer can
linkify links to sibling documents (module-level UI registry, empty by
default, so nothing changes without config).
Validation: entries must be dot-led, lowercase-normalized, and free of path
separators, globs and whitespace. Invalid entries are dropped silently,
built-ins are deduplicated, and `.env` is denylisted so config can never
register it (annotate copies file contents into the data dir).
Deliberately unchanged: the Pi plan-write allowlist (ALLOWED_PLAN_EXTENSIONS
in tool-scope.ts) and Edit Mode source save (SOURCE_SAVE_FILE_REGEX), which
keep their own narrower allowlists.
* fix(annotate): deny the dotenv family and sandbox config-aware tests
Review follow-ups on #1309:
- deny the whole dotenv family (.prod.env, .env.local, ...) in
normalizeMarkdownExtensions, not just the exact .env name
- resolve config.json path per call instead of at module scope so
PLANNOTATOR_DATA_DIR sandboxing works in single-process test runs
- stop resolve-file.test.ts reading the real user config: pure
predicate imports plus pinned empty extras on every resolve call
- add the config.json -> memo -> predicate integration test using
resetMarkdownExtensionsCache under a temp data dir
* test(call-flow): make the stale-read advert test self-sufficient
The read-only GET only probes the node runtime while Call flow is
enabled. The stale-read test relied on earlier tests' settings POSTs
leaking callFlow=true through the process-frozen config path; with lazy
config resolution each sandbox is genuinely isolated, so the test now
enables Call flow in its own data dir. Locally the dependency was
masked by an fnm-shimmed sem sidecar spawning node coincidentally.
* feat: harden mobile comment composition
* fix(ui): keep mobile app inside Safari viewport
* docs: record physical mobile triage
* fix(ui): extend plan canvas behind Safari controls
* fix(ui): let mobile plans drive Safari chrome
* fix(ui): release Safari top edge on mobile plans
* docs: triage mobile feedback and close phase 1b
* fix(ui): harden compact touch behavior
- annotate --tailscale publish failures now exit through
annotateStartupFailureExitCode: exit 2 under a strict gate
(--require-approval / --result-file), where exit 1 is reserved for "the
reviewer did not approve, decision record published". Non-strict annotate
and review keep the documented exit 1.
- the annotate client lease (auto-dismiss on abandonment) is forced off
while tailnetPublished is set: --tailscale reads as local to the CLI
predicate, but clients connect through the serve proxy, and a proxy
disconnect longer than the grace would dismiss a live review. Same
rationale as remote/shared sessions; decided at the single point both
the /api/plan advert and the SSE endpoint read.
- the SIGHUP-to-process.exit route moved from an unconditional CLI-entry
listener into enableTailscaleServe's success path, installed only once a
serve mapping exists. Any SIGHUP listener overrides the ignored
disposition nohup depends on, so plain sessions now keep zero listeners
and "nohup plannotator review &" survives terminal close again;
--tailscale sessions still tear their mapping down on HUP (exit 129).
- validateGuideOutput explains a fully-invalidated guide whose refs named
files outside the changeset (count plus up to 3 example paths, with a
pointer to the Commits panel) instead of the bare generic message; the
generic message stays for genuinely structural emptiness. The informative
error now flows through onJobComplete to the job failure card; Pi picks
the change up via the vendored guide-review copy.
* feat(remote): resolve urlHost auto from Tailscale for advertised URLs
PLANNOTATOR_URL_HOST=auto (or config urlHost: "auto") detects this
machine's tailnet host at first use in a remote session: MagicDNS name
from tailscale status --json, falling back to the single tailscale ip -4
CGNAT address. Detection is cached per process, never spawns in local
sessions, warns once and falls back to localhost on failure, and stays
strictly display-only: binding remains governed by PLANNOTATOR_REMOTE.
Pure parsers live in the new @plannotator/shared/tailscale module,
vendored to the Pi extension; both runtimes mirror the resolution.
* feat(remote): render a terminal QR code for remote-ready session URLs
Remote sessions print their advertised URL as the lifeline; the usual
next step is opening it on another device (iPad, phone, laptop off the
VPS). handleServerReady now also renders a compact unicode QR of that
URL via the zero-dependency uqr package, TTY-gated so piped stderr and
hook transcripts keep only the plain URL line.
Pi keeps URL-only parity: its ready surface is an in-chat notification,
not a TTY stream, so a QR block would not render there.
* feat(cli): first-class --tailscale mode for review and annotate sessions
plannotator review --tailscale (also annotate and annotate-last/last)
publishes the session over the user's tailnet: the server stays
loopback-bound and the CLI orchestrates tailscale serve --bg
--https=<port> http://127.0.0.1:<port>, then advertises the HTTPS
tailnet URL with a terminal QR code. Nothing listens beyond localhost
and nothing is ever public (serve, never funnel).
Guarantees: preconditions fail with actionable errors (CLI missing,
daemon down or logged out); a pre-existing serve mapping on the chosen
port aborts instead of being stolen and other ports are never touched;
every mapping the process creates is torn down on normal completion,
SIGINT/SIGTERM, and errors via the exit-routed cleanup handler. When
combined with PLANNOTATOR_REMOTE or SSH detection, --tailscale wins and
forces local mode with a stderr notice, which also restores the random
local port so simultaneous sessions get distinct serve mappings.
* fix(remote): await tailscale-ready failures, harden serve teardown and conflict detection
Review fixes for #1280 (external review plus internal security review).
Startup failures no longer hang the session: startReviewServer and
startAnnotateServer now await async ready handlers and stop the server
on rejection, and the CLI's --tailscale ready path resolves publishing
failures itself with an actionable stderr message and exit 1. Under the
bang-prefix skill a hanging loopback server blocked the whole Claude
Code prompt.
Serve teardown is checked, not assumed: a failed off retries once, then
warns with the exact manual command, and a port is only forgotten after
a successful off. SIGHUP (terminal close) is now routed through
process.exit like SIGINT/SIGTERM so exit-time cleanup runs. Docs no
longer claim guaranteed cleanup: --bg mappings survive SIGKILL and
reboots, and the manual removal command is documented.
Conflict detection sees foreground serve sessions (Foreground.*.TCP),
which Tailscale prefers over background mappings, and fails CLOSED on
unrecognizable serve status output instead of assuming the port is
free. The extracted serve URL must match the requested port, so a
version-dependent output shape cannot advertise another mapping's URL.
The annotate agent terminal is gated off by default under --tailscale
behind the existing PLANNOTATOR_AGENT_TERMINAL_REMOTE opt-in: the PTY
token is not an auth boundary against network peers, and tailnet
reachability implies terminal reachability.
Also: --tailscale is rejected with a clear error on unsupported
subcommands and documented in review/annotate/annotate-last and
top-level help; the remote-ready QR renders only for URLs actually
reachable off-machine (never localhost); urlHost is suppressed for
--tailscale runs so the local-session warning cannot mislead; the
duplicated auto-host resolution moved into the shared vendored module;
tailscale-serve tests restore module and process state via a reset
seam.
* 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.
Ports five small items from the closed parallel PR #1185 into the
tolerant annotate argument resolution that landed in #1183 (#1182):
- Bug fix: the token probe tested the raw token against the URL regex,
but the pipeline strips the @ reference marker and wrapping quotes
first, so a multi-token 'annotate @https://example.com/page and
summarize it' probed to nothing and emitted the handoff instead of
opening the URL. The probe now unwraps with stripAtPrefix before the
regex and returns the unwrapped form (the pipeline re-strips
harmlessly). Tests cover @-prefixed and quote-wrapped URLs as
multi-token candidates.
- Test ports: absolute-path candidate, the wider plain-text set (.txt,
.yaml) guarding ANNOTATABLE_DOC_REGEX breadth, the scoped-package
literal-@ fallback against a real @scope/ directory, and the
whole-un-split-string preference over its own tokens ('Meeting
Notes.md' wins over a resolving 'Notes.md' token) covering
annotateInputNamesExistingTarget.
- Defensive scan: the strict-mode source-scan test now asserts the
annotate startup block gates tolerance on !strictAnnotate via
isStrictAnnotateInvocation, since an inverted gate cannot be
spawn-tested without starting a server.
- DRY: the strict predicate was defined twice (strict-annotate-result
exit-code helper and the index.ts tolerance bypass). Extracted
isStrictAnnotateInvocation with a StrictAnnotateFlags type; both
sites use it so the exit-code path and the tolerance bypass can
never drift. Behavior byte-identical; existing subprocess tests
unchanged.
- Docs: the tolerant-resolution section now cites #872 (commit
aac5aacb) for why the bang prefix is deliberate and states that
argument-shape issues belong in the CLI's resolution, not the skill
templates.
Refs #1185, #1182
Co-authored-by: Josh Nichols <josh.nichols+agent@gusto.com>
* fix(annotate): resolve natural-language arguments or hand off to the agent
Claude Code skills run the CLI through a bash-substitution prefix that
executes before the model sees anything, so any trailing natural language
in /plannotator-annotate died with 'File not found: the'. Worse, a
non-zero exit from that prefix aborts the whole prompt before the model
runs (verified empirically), so the error was never even visible to the
agent.
Three-tier resolution in the binary's annotate argument handling, shared
by every host via packages/shared/annotate-target.ts:
1. Fast path: probe each whitespace-delimited token; exactly one naming
an existing file, URL, or folder proceeds with it directly.
2. Ambiguity: two or more tokens resolve; error naming every candidate,
never guess.
3. Handoff: nothing resolves; emit an agent-addressed message echoing
the words tried and asking the reading agent to interpret the request
and re-run with a concrete target, preserving flags. In plain mode it
lands on stdout with exit 0, the only combination that reaches the
model through the bang prefix; in --json/--hook mode it goes to
stderr with exit 1 so machine stdout stays clean.
Single-token invocations run the unchanged pipeline first, so bare
correct invocations are byte-identical. Strict gates (--require-approval
or --result-file) bypass the tolerance entirely: a typo'd path stays a
startup failure with exit 2 and no agent-facing prose.
The CLI resolution pipeline moves to apps/hook/server/annotate-resolution.ts
(returns typed outcomes instead of exiting) so the token fallback can run
it once with a selected candidate; OpenCode and Pi wire the same shared
selection into their own not-found paths. Skill bodies gain one line
telling the agent to re-run with a concrete target when the command
reports unresolvable arguments.
Closes#1182
Reported-by: @technicalpickles
* fix(annotate): harden tolerant resolution per review
Review fixes for the three-tier annotate argument handling:
- A single unresolvable token now falls through to the legacy pipeline
verbatim: 'annotate nope.md' is exit 1 with 'File not found: nope.md'
again in every non-strict mode, instead of an exit-0 handoff that
fail-opened scripts gating on the exit code. The handoff fires only
when two or more words resolve to nothing.
- Unrecognized dash-prefixed tokens disable tolerance instead of being
skipped, so a typo'd flag ('--no-jna') errors the way it did on base
rather than silently fetching via Jina. Known flags are stripped
before selection as before.
- Token selection now receives the original argv tokens, so a quoted
missing path ('my notes.md') is probed as one token and can never be
re-split into a silently resolving 'notes.md'.
- Bare directory names only count as fast-path candidates when they are
the sole argument; a stray word matching a directory (or '.') hands
off instead of opening folder mode. Explicit paths like 'src/' keep
resolving, and the bare-existence probe fallback is file-only.
- The handoff re-run suggestion echoes content flags only (--markdown,
--no-jina, --render-html), never transport flags (--gate, --json,
--hook).
- New subprocess suite (annotate-cli.test.ts) spawns the real CLI entry
and pins the contract: single-token typo exit 1, strict invocations
(--require-approval and --result-file) exit 2 with empty stdout and
no handoff prose, unknown-flag error, quoted-token preservation, and
the directory-hijack case. Placeholder dist files are created when a
build is absent so the suite runs in CI.
- The copilot and gemini annotate command bodies gain the same handoff
instruction as the Claude, core, and kiro skills.
- AGENTS.md documents the three tiers under Annotate Flow and corrects
the strict-section sentences that claimed non-strict behavior was
fully unchanged; the marketing annotate doc mentions the tolerant
arguments.
Refs #1182
#1143 wired abandoned-gate dismissal into three of the four
startAnnotateServer call sites. The OpenCode annotate-last bridge takes
gate from stdin JSON rather than CLI flags and was missed, so
/plannotator-last --gate under OpenCode still hung on waitForDecision
forever once every review tab was abandoned: exactly the hang that
commit set out to close.
The bridge's inputs map onto the same predicate the other three use:
gate from the stdin payload, json unconditionally true because
emitOpenCodeAnnotateOutcome is the branch's only output path and always
writes a structured record the bridge parses back, hook false because no
flags are parsed here.
The new test scans every startAnnotateServer call site in index.ts
rather than pinning this one line, so the next site added cannot repeat
the omission.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
A direct local `plannotator annotate --gate --json` waits for one
authoritative decision. If every review surface disappears without
approving, sending feedback, or exiting, the caller blocks forever: the
server has no notion of whether a client ever connected, whether another
tab is still open, or whether a disconnect is a reload.
Page lifecycle events cannot answer that. `pagehide` and `beforeunload`
also fire on reload and navigation, so dismissing from them ends reviews
the user expects to resume. Use connection presence instead, which is
exactly what the transport can observe.
Local direct structured gates advertise a client lease in /api/plan and
serve /api/annotate/client-lease as SSE. One open stream is one connected
review surface. The server heartbeats every 5s and, only after at least
one client has connected, starts a 30s reconnect grace when the last one
disconnects. A reconnect inside the grace continues the same review;
expiry resolves the gate through the same path as explicit Close, so it
produces an ordinary `dismissed` decision and inherits the strict-result
contract unchanged. Approve, feedback, explicit exit, and server stop all
cancel a pending expiry.
Presence lives in two runtime-independent pieces so Bun and Pi cannot
drift. createAnnotateClientLeaseTracker owns first-client, active-count,
reconnect, cancellation, and one-shot expiry. createAnnotateClientLease-
StreamSession owns one connected client: acquire the slot, write the
ready comment, heartbeat, release exactly once. Each server passes only
its own write primitive (a ReadableStream controller for Bun, res.write
for Pi). A write that fails closes the session, because a stream that can
no longer be written to is a client that is no longer present; holding
the slot there would make the gate un-dismissable for the rest of the
run, which is reachable only through a half-open connection and so is
covered by unit tests rather than an integration test.
Scope is deliberately narrow. The capability stays off for remote and
shared sessions, where tunnel disconnects would read as abandonment, and
off for hook transport, legacy plaintext, archive, plan, review, and
folder-picker sessions. A session that never receives its first client
never auto-dismisses, so browser-launch failures still need a caller-side
timeout.
Decision settlement is explicit for the same reason: a connected surface and
the lease can both try to settle the session, and the awaited promise ignoring
the second resolve was not enough. The loser still deleted the reviewer's draft
and answered ok, so a tab reported success for a decision the caller never
received. createAnnotateDecisionSettler makes the winner explicit; a loser
changes nothing and answers 409. Expiry deliberately keeps the saved draft,
unlike explicit Close, so an abandoned review stays recoverable.
Stopping the server closes live lease streams instead of only releasing their
slots, so a long-lived host process does not retain a heartbeat timer and an
open response for every finished session.
* fix(hook): add Copilot session lock detection
Copilot CLI exports no identifying environment variable, so nothing distinguishes a Copilot session from a plain shell. Match ancestor pids against session-state inuse locks to find the live session, and only accept a match when the lock owner still names a copilot process, since locks can outlive their session and pids get reused.
* fix(hook): route annotate-last to the live Copilot session
Under Copilot CLI, annotate-last silently fell back to the default transcript reader and annotated a message from a different tool. Take the Copilot branch when an ancestor process holds a session lock, or when PLANNOTATOR_ORIGIN=copilot-cli is set with the cwd heuristic as fallback, and report origin copilot-cli to the annotate server.
* fix(hook): prefer ancestor lock match in copilot-last
The cwd heuristic can pick a stale session when several exist for one repo. Resolve the session locked by an ancestor copilot process first and keep the heuristic as fallback.
* docs(cli): document copilot-last in help
The subcommand worked but was missing from the top-level usage and the per-subcommand help map.
* fix(hook): read annotate-last from the transcript tree, not file order
Claude Code session logs are append-only and tree-shaped: every entry
records the entry it follows in `parentUuid`. `/rewind` writes nothing at
all. The next committed message simply re-parents to an earlier entry,
leaving everything after it orphaned in the file forever.
`extractRecentRenderedMessages` scanned bottom-up in file order, so those
orphans were still offered in the annotate-last message picker even though
they are no longer part of the conversation. On a rewound session in this
repo the picker listed 10 messages where only 7 are live.
Add `resolveActiveBranchIndices`, which walks `parentUuid` from the newest
id-bearing entry back to the root. It returns indices rather than a
filtered array so callers keep reporting real file line numbers, and
returns null on a chain it cannot trust (no ids, dangling parent, cycle) so
callers degrade to the previous file-order read instead of returning
nothing. Note the newest entry is not always the last line: `last-prompt`,
`ai-title`, `mode` and `file-history-snapshot` carry no ids and are often
written last.
Opt in at the Claude Code call site only. Droid's call site, Codex and
Copilot (separate parsers), and Pi/OpenCode/Amp (live APIs, no transcript
reads) are all unaffected. Both new parameters default to off.
Checked against 311 local transcripts: every one walks cleanly to the root
with no dangling parents or cycles, and the default pick is byte-identical
under both readings in all 298 that contain a message. That is expected,
since a committed rewind's new branch is always the newest lines in the
file. The picker is where the difference shows up.
Test fixtures previously assigned random `parentUuid`s, which left every
entry an orphan and made branch resolution untestable, so `buildLog` now
links them into a real chain and `buildRewoundLog` models a fork.
* fix(hook): fail open when the active branch has no assistant messages
A /compact boundary is written with parentUuid: null, so it is a tree
root: the active-branch walk stops there and a freshly-compacted session
yields zero messages. Callers treat an empty result as "wrong log file"
and walk off to an older session, so fail open to the file-order read
instead. Adds tests for the compaction cut, the fallback, and the
post-compaction recovery.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
---------
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
* feat(annotate): add strict atomic result output
* feat(annotate): exit 2 for strict-gate usage and publication errors
Adopt the grep convention for the strict annotate gate's exit codes:
0 = approved, 1 = negative human outcome (annotated/dismissed under
--require-approval), 2 = the gate itself was misconfigured or could not
start/deliver a decision. Previously all usage/startup/validation
failures shared exit 1 with "reviewer did not approve", so callers could
not tell a denied review from a broken gate.
- parseStrictAnnotateOptions failures (bad flag combos, strict flags
outside annotate --gate --json) now exit 2
- --result-file preflight failures (missing parent, pre-existing or
dangling-symlink destination) now exit 2
- post-decision publication failures (destination raced into existence,
hard links unavailable, stdout write failure) now exit 2: they deliver
no decision record at all, so the code's own fail-closed handling
presents them as environment errors, never as a reviewer outcome --
and never approval, since only 0 means approved
- decision outcomes keep 0/1 exactly as before; signal deaths keep 128+n
- document the contract in AGENTS.md and the annotate-gates guide
Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
* feat(annotate): preserve notes on structured approval
* test(pi): use exact annotate outcome import
* fix(annotate): exit 2 for strict-gate startup failures
The six startup-failure sites in the annotate path (missing path, unreachable
URL, empty folder, ambiguous name, missing/unsupported file, oversized file)
run after flag parsing and exited 1. Under --require-approval / --result-file,
1 is the "reviewer requested changes" signal, so a typo'd path made automation
misclassify a configuration error as a legitimate rejection.
Route those sites through exitAnnotateStartupFailure(), which picks its code
from the already-parsed strict options via the new pure helper
annotateStartupFailureExitCode(). Non-strict invocations still exit 1 with
byte-identical stderr; strict invocations exit STRICT_GATE_ERROR_EXIT_CODE (2).
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
* fix(annotate): emit the strict decision on stdout before publishing it
writeResultFile ran before the decision JSON reached stdout. On a filesystem
without hard links (exFAT, FAT32, most SMB/NFS, some container bind mounts)
publication fails deterministically, the catch exited 2 with nothing written
anywhere — and the reviewer's autosaved draft had already been deleted by the
feedback flow, so their completed decision was lost.
Emit the stdout record first, then publish the result file. Exit semantics are
unchanged: a publication failure still exits 2, but the decision has reached
stdout by then. Only a stdout write failure now leaves no record at all.
Correct the docs and comments that claimed exit 2 delivers no decision record:
it means the result *file* was not published. Also document the two publication
caveats: the 0600 mode is a no-op on Windows, and the atomic link/rename is not
followed by a parent-directory fsync, so publication is atomic but not
crash-durable.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
* fix(annotate): parse linked docs with the render-side frontmatter rule on export
buildCompleteAnnotateFeedback re-parsed each linked document with
parseMarkdownToBlocks(entry.markdown) — no options, so frontmatter
stripping defaulted on. The render side parses with
{ frontmatter: shouldStripFrontmatter(path) }.
For plain-text linked docs (.yaml/.json/.toml/…) a leading `---` is real
content, not frontmatter: a multi-document YAML opens with it. Stripping
it on the export side shifted every block id, so ordinary Send Feedback
and deny emitted wrong `(line N)` labels — or dropped them entirely when
the annotation's block no longer existed.
Pass the same shouldStripFrontmatter(filepath) option at the export call
site so both sides agree.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
* fix(annotate): carry the message scope through approve-with-notes
/api/feedback forwards selectedMessageId and feedbackScope; /api/approve
dropped them. Pi resolves the anchor message from those fields, so notes
delivered on the approve path anchored to the last message instead of the
one the reviewer picked in a multi-message annotate-last session — while
Send Feedback in the same session anchored correctly.
Forward both fields on the approve path in the Bun and Pi servers, and
have the client build the approval body with the same scope resolution
Send Feedback uses (extracted as getFeedbackMessageScope so the two can
no longer drift).
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
* docs(annotate): tell agents an approval may carry notes
The skill and slash-command files still described `"decision": "approved"`
as "acknowledge and stop", with no mention of the feedback field the gate
can now attach — so an agent reading them would silently drop the
reviewer's approval notes.
Update the Claude core/claude skills, the Copilot commands, the Gemini
annotate command, and the annotate command reference so the approved
branch names the optional feedback field and says what to do with it:
carry it into subsequent work, do not treat it as a change request.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
* docs(annotate): document the real approvedWithNotes default
The default annotate.approvedWithNotes template is
`{{contextBlock}}{{feedback}}`, not `{{context}}` on its own line, and
{{contextBlock}} was missing from the variable table entirely.
Show the actual default, add {{contextBlock}} to the variable table, and
explain why the default prefers it: it collapses to nothing for message
annotations instead of leaving a stray blank line.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
---------
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
* feat(annotate): add strict atomic result output
* feat(annotate): exit 2 for strict-gate usage and publication errors
Adopt the grep convention for the strict annotate gate's exit codes:
0 = approved, 1 = negative human outcome (annotated/dismissed under
--require-approval), 2 = the gate itself was misconfigured or could not
start/deliver a decision. Previously all usage/startup/validation
failures shared exit 1 with "reviewer did not approve", so callers could
not tell a denied review from a broken gate.
- parseStrictAnnotateOptions failures (bad flag combos, strict flags
outside annotate --gate --json) now exit 2
- --result-file preflight failures (missing parent, pre-existing or
dangling-symlink destination) now exit 2
- post-decision publication failures (destination raced into existence,
hard links unavailable, stdout write failure) now exit 2: they deliver
no decision record at all, so the code's own fail-closed handling
presents them as environment errors, never as a reviewer outcome --
and never approval, since only 0 means approved
- decision outcomes keep 0/1 exactly as before; signal deaths keep 128+n
- document the contract in AGENTS.md and the annotate-gates guide
Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
* fix(annotate): exit 2 for strict-gate startup failures
The six startup-failure sites in the annotate path (missing path, unreachable
URL, empty folder, ambiguous name, missing/unsupported file, oversized file)
run after flag parsing and exited 1. Under --require-approval / --result-file,
1 is the "reviewer requested changes" signal, so a typo'd path made automation
misclassify a configuration error as a legitimate rejection.
Route those sites through exitAnnotateStartupFailure(), which picks its code
from the already-parsed strict options via the new pure helper
annotateStartupFailureExitCode(). Non-strict invocations still exit 1 with
byte-identical stderr; strict invocations exit STRICT_GATE_ERROR_EXIT_CODE (2).
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
* fix(annotate): emit the strict decision on stdout before publishing it
writeResultFile ran before the decision JSON reached stdout. On a filesystem
without hard links (exFAT, FAT32, most SMB/NFS, some container bind mounts)
publication fails deterministically, the catch exited 2 with nothing written
anywhere — and the reviewer's autosaved draft had already been deleted by the
feedback flow, so their completed decision was lost.
Emit the stdout record first, then publish the result file. Exit semantics are
unchanged: a publication failure still exits 2, but the decision has reached
stdout by then. Only a stdout write failure now leaves no record at all.
Correct the docs and comments that claimed exit 2 delivers no decision record:
it means the result *file* was not published. Also document the two publication
caveats: the 0600 mode is a no-op on Windows, and the atomic link/rename is not
followed by a parent-directory fsync, so publication is atomic but not
crash-durable.
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
---------
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
* feat(annotate): accept common plain-text config formats (.yaml, .json, .toml, …)
Annotate previously rejected every file that wasn't .md/.mdx/.txt (or
.html/.htm), even though the pipeline reads files as UTF-8 text and
renders anything. Widen the accepted set to unambiguously plain-text
config/data formats: .yaml .yml .json .jsonc .json5 .toml .ini .cfg
.conf .properties .csv .tsv .log .xml .env.example. They render exactly
the way .txt renders today.
- New single source of truth: packages/core/annotatable.ts
(ANNOTATABLE_TEXT_REGEX / ANNOTATABLE_DOC_REGEX + predicates),
re-exported through @plannotator/shared/resolve-file and vendored into
the Pi extension.
- .env stays excluded (commonly holds secrets; annotate history copies
file contents into the data dir). Source-code extensions stay with
code review.
- Single-file accept + bare-filename fuzzy search widen in
resolveMarkdownFile; folder discovery and the file-browser listing
widen in all three runtimes (hook CLI, OpenCode, Pi).
- /api/doc gains a `doc=1` param set by the file browser so extensions
that overlap CODE_FILE_REGEX (.yaml/.json/.toml/.ini/.xml) render as
annotatable documents there while code-file links inside documents
keep the syntax-highlighted popout.
- Error messages now list the wider set; docs updated (AGENTS.md,
marketing annotate page).
Closes#1029
Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
* fix(annotate): frontmatter, size caps, edit-guard, and skill docs from review
Review fixes for #1099:
- Frontmatter: `--- … ---` stripping is a markdown convention; for
non-markdown annotatable sources (multi-document YAML, .txt starting
with ---) the delimiters are real content. parseMarkdownToBlocks gains
a { frontmatter } option and the editor keys it off the active
document's path via shouldStripFrontmatter() (strip for .md/.mdx and
pathless/converted sources; keep raw for other annotatable text).
- Size caps: new shared MAX_ANNOTATABLE_FILE_BYTES (2MB — same limit the
code-file popout always had) now guards the annotate CLI single-file
read in all three runtimes and the /api/doc document branches in both
servers. Also applies to .md/.txt (behavior change for pathological
inputs; previously unbounded).
- Editing guard: mid-edit file opens gate on isSourceSaveFilePath
(.md/.mdx/.txt) instead of the wider annotatable set — config files
are view-only, so switching to one mid-edit no longer silently
downgrades "Done editing" to feedback-only edits.
- Skill docs: plannotator-annotate SKILL.md (core + Kiro) now mention
the plain-text config formats.
Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
Adds current-architecture GitButler workspace, stack, and branch review support across Bun and Pi while preserving the existing Git, JJ, and P4 paths.
Co-authored-by: Dan Susman <56033661+dansusman@users.noreply.github.com>
Since Claude Code 2.1.199, a PermissionRequest "allow" decision for
ExitPlanMode is silently discarded unless it echoes updatedInput, because
ExitPlanMode requires user interaction and is not an MCP tool. The CLI then
falls back to its built-in approval dialog, so clicking Approve in the
Plannotator UI never returned control to the agent session (deny was
unaffected). This matches the reported behavior on 2.1.199 through 2.1.202.
Echo the original tool_input (in scope as event.tool_input) as updatedInput
in the Claude Code allow decision. Backward compatible: older Claude Code
versions treat the echoed input as unchanged (verified no-op on 2.1.198).
Fixes#995
* feat(annotate): version diff for annotated files
Annotate mode never tracked version history, so the existing Plan Diff
(highlighted diff vs a previous version) only worked in plan mode. Wire
per-file version history into the annotate server so the same diff UI —
badge, Version Browser, block-level comments — works when annotating a
standalone .md/.txt/.html file.
- key history by file path (stable across edits) rather than the plan
flow's heading+date slug
- save the markdown (or raw HTML source) to history on each open, expose
previousPlan + versionInfo + diffCurrent on /api/plan
- add /api/plan/version and /api/plan/versions to the annotate server
Markdown lights up end to end; HTML needs frontend follow-ups (feed the
HTML source as the diff content, surface the badge on the html surface,
default to source diff mode).
* feat(annotate): rendered HTML version diff with inline highlights
For --render-html files, render the version diff as the real page with
inline <ins>/<del> highlights instead of a markdown/source diff:
- add packages/shared/html-diff.ts: a tag-aware htmlDiff() that wraps
changed text in <ins>/<del> while keeping tags balanced (script/style
opaque). 9 unit tests.
- annotate server computes diffHtml = rewriteHtml(htmlDiff(prev, current))
and exposes it on /api/plan
- HtmlViewer: inject ins/del highlight CSS, add a 'Show/Hide changes'
toggle in its action bar
- App: store diffHtml, swap the iframe to the diff page when toggled, and
suppress the markdown block-diff path on the HTML surface
Commenting still works because the diff page renders through the same
HtmlViewer iframe bridge.
* docs(annotate): document the annotate version diff + endpoints
* feat(annotate): mirror version diff into the Pi server
Parity for the Pi (node:http) runtime: per-file version history,
previousPlan/versionInfo/diffCurrent + diffHtml on /api/plan, the
/api/plan/version[s] endpoints, and project wiring from the Pi CLI.
Vendors @plannotator/shared/html-diff into pi-extension/generated.
* review fixes: pi diff dependency, attr-aware tokenizer, history opt-out, hide dead version picker on HTML
- apps/pi-extension/package.json: declare the 'diff' dependency —
generated/html-diff.js imports it at module load, so a standalone Pi
install failed to resolve it and broke every annotate session (the
monorepo masked this via root hoisting)
- packages/shared/html-diff.ts: tag tokenizer now consumes quoted
attribute values whole, so a '>' inside title="a > b" no longer
splits the tag and corrupts the diff output; 3 regression tests
- annotate history is now gated by PLANNOTATOR_ANNOTATE_HISTORY /
config.annotateHistory (default on) and disclosed in AGENTS.md —
it writes copies of annotated files into the data dir, which users
should be able to see coming and turn off
- packages/editor/App.tsx: hide the sidebar Versions tab on the HTML
surface — the base-version picker has nothing to drive there (the
HTML diff is fixed to current-vs-previous); the viewer's Show
changes toggle is unaffected
* fix(ui): document content clears the badge cluster dynamically
The repo/diff badge cluster is absolutely positioned in the card's top
padding, sized by guesswork (py-5..py-12). One chip row fit; the diff
badge's second row overflowed into the H1, and mobile wrapping made the
badge sit on top of the heading. Measure the cluster (ResizeObserver)
and insert exactly the clearance it needs — zero when it fits, so
existing single-row layouts don't shift. Pre-existing plan-mode bug
surfaced by the annotate version diff.
---------
Co-authored-by: Edouard Gouilliard <edouard.gouilliard13@gmail.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
* fix(cli): print per-subcommand help instead of launching the UI
`plannotator review --help` (and other subcommands) fell through to their
command branch because only top-level `--help` was handled. For `review`,
`--help` was parsed as a non-URL positional, dropping into local review
mode and opening a browser tab. When Claude Code probes the CLI with
`--help`, that stray tab's close injects a bogus "no feedback → proceed"
signal into the session.
Handle `--help`/`-h` for every user-facing subcommand (review, annotate,
annotate-last/last, setup-goal, archive, sessions) before any subcommand
branch can run: print command-specific usage on stdout and exit 0. Also
accept `-h` at the top level and advertise per-command help there.
Fixes#964
* fix(cli): handle `improve-context --help` too
The top-level help advertises `plannotator <command> --help`, but
`improve-context` (the only internal hook command listed there) had no
help entry, so `improve-context --help` fell through to the hook branch
and emitted additionalContext JSON instead of usage. Add a help entry so
every advertised command responds to --help.