Commit Graph

263 Commits

Author SHA1 Message Date
Michael Ramos 64062af9a1 feat: Portable Guided Reviews — export, share links, agent-authored guides, guides.show (#1324)
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
2026-08-16 12:17:13 -07:00
Michael Ramos aa0bf860d8 chore: bump version to 0.27.3 2026-08-13 16:06:13 -07:00
Michael Ramos 8b9dfe7e5f chore: bump version to 0.27.2 2026-08-13 11:36:16 -07:00
Michael Ramos d2d2dba7fa feat(annotate): configurable extra markdown extensions (#1309)
* 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.
2026-08-13 09:47:18 -07:00
Michael Ramos e181b824cc Mobile-safe plan and code comment composition (#1297)
* 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
2026-08-13 08:58:55 -07:00
Michael Ramos d3633c9c52 Mobile foundation and quieter first run (#1295)
* feat: establish mobile foundation and simplify onboarding

* fix(ui): finish mobile foundation cleanup
2026-08-13 08:45:00 -07:00
Michael Ramos ef49c701c2 chore: bump version to 0.27.1 2026-08-12 17:07:36 -07:00
Michael Ramos d0d971a3bf chore: bump version to 0.27.0 2026-08-12 14:14:35 -07:00
Michael Ramos ed6f44bf2e fix(release): tailscale gate exit codes and lease gating, conditional SIGHUP, informative guide validation error (#1286)
- 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.
2026-08-12 14:04:59 -07:00
Michael Ramos 5f33b72b2f feat(remote): tailnet auto-advertise, ready QR code, and a first-class --tailscale mode (#1280)
* 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.
2026-08-12 12:07:30 -07:00
Michael Ramos 3245310aa8 feat(review): add optional CallDiff call-flow analysis (#1268)
* feat(review): add optional CallDiff call-flow analysis

* fix(review): harden CallDiff integration
2026-08-11 13:18:35 -07:00
Michael Ramos 2fff8756d9 chore: bump version to 0.26.8 2026-08-10 17:00:33 -07:00
Michael Ramos 62c1eab119 chore: bump version to 0.26.7 2026-08-09 23:02:38 -07:00
Michael Ramos d579ff8db2 chore: bump version to 0.26.6 2026-08-09 21:07:30 -07:00
Michael Ramos 9c40ffadcc chore: bump version to 0.26.5 2026-08-09 17:49:14 -07:00
Michael Ramos d5ae439f7a chore: bump version to 0.26.4 2026-08-07 15:13:27 -07:00
Michael Ramos c760fc522b chore: bump version to 0.26.3 2026-08-07 14:32:16 -07:00
Michael Ramos bbae458e5a chore: bump version to 0.26.2 2026-08-06 00:50:56 -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 50a54c872b chore: bump version to 0.26.1 2026-08-05 11:39:02 -07:00
Michael Ramos f555168deb chore: bump version to 0.26.0 2026-08-05 09:19:51 -07:00
Michael Ramos 46f1e8d5b2 fix(annotate): recognize wrapped URLs in token probe and port #1185 coverage (#1187)
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>
2026-08-03 13:25:41 -07:00
Michael Ramos 747b5ea7e6 fix(annotate): resolve natural-language arguments or hand off to the agent (#1183)
* 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
2026-08-03 10:14:37 -07:00
Michael Ramos 2ab377f77d fix(uninstall): require host cleanup (#1177) 2026-08-03 10:11:29 -07:00
Michael Ramos 93b66e0ab2 feat(cli): add safe uninstall lifecycle (#1170)
* feat(cli): add safe uninstall lifecycle

* fix(uninstall): harden cleanup and add Windows QA

* fix(uninstall): detach Windows self-delete worker

* fix(uninstall): preserve PowerShell worker syntax

* fix(uninstall): harden purge and host recovery

* fix(uninstall): revalidate purge boundary

* fix(uninstall): unlink managed link entries safely
2026-08-01 10:26:42 -07:00
Michael Ramos 7cd023cbc7 docs: correct privacy and network claims (#1163)
* docs: correct privacy and network claims

* docs: address privacy review findings

* docs: clarify GitLab avatar lookup concurrency
2026-07-31 11:22:19 -07:00
Michael Ramos b5cf065cba chore: bump version to 0.25.1 2026-07-30 03:15:51 -07:00
Michael Ramos c353413b55 fix(hook): enable the annotate client lease on OpenCode's last-message bridge
#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
2026-07-30 03:03:13 -07:00
Raúl c750427ab8 feat(annotate): dismiss abandoned gate sessions (#1143)
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.
2026-07-29 23:02:49 -07:00
mararn1618 5d1544fa32 fix(hook): route annotate-last to the live Copilot CLI session (#1150)
* 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.
2026-07-29 22:59:52 -07:00
BrandonNoad 8d8e643976 fix(hook): read annotate-last from Claude Code's transcript tree, not file order (#1141)
* 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>
2026-07-29 22:59:43 -07:00
Michael Ramos 68c1291cd7 chore: bump version to 0.25.0
Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
2026-07-27 00:39:19 -07:00
Raúl 9a450a69e7 feat(annotate): preserve notes on structured approval (#1092)
* 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>
2026-07-26 21:09:28 -07:00
Raúl 5aaa420080 feat(annotate): add strict atomic result output (#1091)
* 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>
2026-07-26 21:05:57 -07:00
Michael Ramos 9bf46e11f3 chore: bump version to 0.24.2 2026-07-21 13:10:42 -07:00
Michael Ramos f9a6c1e39d feat: annotate accepts YAML, JSON, TOML and other plain-text files (#1099)
* 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
2026-07-20 15:33:42 -07:00
Michael Ramos 2594b374d2 chore: bump version to 0.24.1
Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
2026-07-20 01:56:34 -07:00
Michael Ramos a9e608d7b6 chore: bump version to 0.24.0
Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
2026-07-19 22:50:01 -07:00
Michael Ramos 56df64c751 Add modern GitButler review support (#1067)
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>
2026-07-17 07:37:50 -07:00
Michael Ramos 95ac6cd2f8 Use the production Totman favicon (#1066) 2026-07-16 16:04:01 -07:00
Michael Ramos d0665571c7 Fix OpenCode plan review cancellation cleanup (#1064) 2026-07-16 14:15:23 -07:00
Michael Ramos 60b5e8d31a Narrow review feedback validation to submitted findings (#1065) 2026-07-16 14:15:07 -07:00
Michael Ramos 29513e1984 chore: bump version to 0.23.1
Claude-Session: https://claude.ai/code/session_01K9G9vurTg1v5uD37GbDMpP
2026-07-12 14:37:49 -07:00
Michael Ramos 69ca6d546c chore: bump version to 0.23.0
Claude-Session: https://claude.ai/code/session_01SFy9fY27SA8g5BtotWPi1G
2026-07-10 08:35:21 -07:00
김영준E 3b2d899e49 fix(hook): echo tool_input as updatedInput so plan approval survives Claude Code 2.1.199+ (#1008)
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
2026-07-10 05:31:15 -07:00
egouilliard-leyton 73efacfa0c feat(annotate): per-file version diff for .md and .html (rendered HTML highlights) (#961)
* 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>
2026-07-06 19:13:10 -07:00
Michael Ramos 82f5648ccb chore: bump version to 0.22.0 2026-07-05 11:57:30 -07:00
Michael Ramos 9bded34324 chore: bump version to 0.21.4 2026-07-01 11:32:47 -07:00
Michael Ramos ec8342c34a chore: bump version to 0.21.3 2026-06-28 11:37:37 -07:00
Michael Ramos 82acc4b0aa fix(cli): print per-subcommand help instead of launching the UI (#974)
* 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.
2026-06-26 13:10:56 -07:00