Architecture decision records
Short records of the decisions that shape the project, newest last. Each spike from the plan gets an entry with its result.
ADR-0001: Derive from vscode-pdf; vendor the prebuilt PDF.js viewer; zero patches by default
Decision. Start from the custom-editor integration in mathematic-inc/vscode-pdf (Apache-2.0) in our own repository, and vendor the pinned prebuilt PDF.js viewer (pdfjs.lock.json, pnpm prepare-pdfjs) rather than build the viewer UI from pdfjs-dist.
Why. pdfjs-dist ships the rendering components but not the viewer application (toolbar, sidebar, annotation-editor toolbar, highlight editor, color picker, localization). Rebuilding that is weeks of work; upstream already solved "full viewer inside a VS Code webview under a strict CSP" in ~7 small files.
Consequences. Derived files keep their Apache-2.0 header and a "Modified by" line (pdfjs.lock.json lists them). Upstream's two patches are replaced by runtime behavior: the PDF.js CSP <meta> is stripped when the HTML is rewritten, and intra-folder links will be routed to VS Code by hooking the link service after initialization. New patches need a header explaining why a runtime hook was impossible. The upstream-watch workflow tracks drift.
ADR-0002: Sidecar JSON is canonical; PDF is synced on save; protected PDFs are never modified
Decision. Highlights and notes live in case.pdf.review.json. On save the PDF is rewritten with matching annotations (pdf-lib) when the file is unencrypted and writable; encrypted or permission-restricted PDFs stay untouched and the notes stay sidecar-only. The extension never removes encryption or permission flags, not even for an exported copy.
Why. Categories, notes and timestamps don't fit the PDF annotation model; JSON diffs cleanly; the source stays byte-identical when it must. Stripping a publisher's technical restriction is circumvention regardless of how easy it is.
ADR-0003: PDF.js highlight editor is the drawing engine; notes live in VS Code, not PDF.js comments
Decision. Use PDF.js's native HighlightEditor (floating button + editor toolbar) with highlightEditorColors mapped to our categories, enableComment: false, and a VS Code sidebar/webview view for notes. All PDF.js internals sit behind src/webview/pdfjsAdapter.ts; an overlay-based adapter is the fallback if the editor proves unstable across upgrades.
Note (M1). PDF.js 6.3.289 has no HIGHLIGHT_DEFAULT_COLOR parameter: the keyboard path (Ctrl+Alt+N) sets the default color through uiManager.updateParams(HIGHLIGHT_COLOR, hex) with nothing selected, calls highlightSelection("keyboard"), and polls for the new editor because creation is deferred behind the mode switch.
ADR-0004: Highlight identity: a uuid in the sidecar and /NM, pdfjsId refreshed on every sync, viewer ids never persisted
Decision. Every highlight gets a UUID from the extension host the first time it appears; the sidecar stores it and the PDF annotation carries it as /NM. Because PDF.js never surfaces /NM, the sidecar also stores pdfjsId, the object reference PDF.js reports (<n>R), refreshed from pdf-lib's output on every embed. PDF.js editor ids live only for one viewer load and stay in a host-side ReconcileSession (src/core/sidecar/reconcile.ts). The webview keeps sending full snapshots; the host diffs them with a pure reconcileSnapshot, so the mapping is unit-tested.
Rules. Resolution order for an editor: existing session binding, the sidecarId the host gave it, a highlight with the same pdfjsId, a tombstone from a recent delete (undo), else a new highlight. Deletion must be proven: an editor the viewer created vanished from the snapshot, or PDF.js reports the embedded annotation deleted (isDeletedAnnotationElement). Absence alone never deletes an embedded highlight, because AnnotationEditorLayer.disable() removes every file-backed editor when the viewer leaves highlight mode. Highlights never materialized in the viewer are kept. File-backed editors the sidecar does not know are foreign annotations and are left alone.
Why. Three id spaces with three lifetimes; keeping the join in one tested place is what stops notes from being lost on undo, reload, mode switches or re-embedding.
ADR-0005: CustomDocumentContentChangeEvent, not CustomDocumentEditEvent
Decision. The editor provider reports model changes to VS Code with CustomDocumentContentChangeEvent (dirty dot, Ctrl+S, "Save changes?", hot-exit backup, files.autoSave) and never with CustomDocumentEditEvent. Dirty means serializeSidecar(model) !== savedSnapshot. Revert reloads the sidecar from disk and reloads the viewer; Save As exports a copy (PDF bytes plus a sidecar beside the destination) and leaves the original untouched.
Why. With edit events VS Code owns undo and redo, and PDF.js has its own undo stack for highlights; two stacks for one gesture would fight. Letting PDF.js keep undo means Ctrl+Z inside the viewer behaves exactly as in PDF.js.
Consequences. Undoing back to the saved state does not clear the dirty flag (VS Code only clears it on save or revert); that is documented rather than worked around. Snapshots that arrive while a reload is pending are ignored, and the reconcile session is reset on every viewerLoaded, so a reload can never look like a mass deletion. Files are written with workspace.fs.writeFile, not temp-and-rename: VS Code treats an in-app rename over an open resource as a delete and closes its editor (found by test/integration/pdf-sync.test.ts, which asserts the tab survives a save); writing the sidecar before the PDF is what protects the notes instead. PDF.js's own save is disabled in the webview (download, save and downloadOrSave are no-ops, the toolbar buttons are hidden, Ctrl+S is forwarded to VS Code): PDF.js otherwise downloads a copy of the edited file on Ctrl+S and from close() on every reload, which inside VS Code lands in the Downloads folder.
ADR-0006: The AI eligibility gate: one chokepoint, a branded attestation, an explicit question, fail closed
Decision. Every AI path (CLI providers and the manual Copy Summary Prompt) runs through ensureAttestation in src/extension/ai/consentGate.ts before any excerpt can leave the machine. The prompt builder (src/core/ai/prompt.ts) requires a branded Attestation object that only createAttestation can produce, so a code path that skips the gate is a compile error (unit-tested with @ts-expect-error). The per-run dialog is phrased as a direct question, "may this document be fed into AI context on this account?", naming the signed-in email (read from the CLI's saved login through src/core/ai/identity.ts, never from asking a model), the document and its own authorization line from page-1 text; the yes is recorded in the sidecar as aiConsent.eligibilityConfirmed. Protected documents re-ask on every run. ai.requiredAccount rules refuse a wrong login with no override; ai.accounts entries give multi-account users isolated CLI login directories that the extension itself passes to the child process. Any failure (identity unreadable, dialog cancelled, rule mismatch, page text unavailable for a line-conditioned rule) cancels the AI step; the report still renders without a summary.
Rules. Only highlights and notes are ever sent, never the PDF, and the provider spawns are shaped to keep that true against prompt injection inside a highlight: the summary is a text-in text-out call, never an agent run. claude -p gets --tools= (every tool disabled; user and project settings ignored), codex exec gets its strictest sandbox (--sandbox read-only), and both run from an empty scratch directory so neither project files nor project agent rules are reachable; Codex's read-only sandbox can still inspect the (empty) disk, which is why the working directory isolation matters. A requiredAccount rule that selects an ai.accounts entry must select one for the active provider; anything else is refused as a configuration error, so the identity the gate verified is always the identity that runs. There is no ai.sendFullText setting: a switch whose off position is the only implemented behavior would mislead (revisit if full-text ever ships). ai.promptFile is deferred with it until the override semantics are designed. On protected documents, CLI providers require a verified login (ai.requireVerifiedAccountForProtected, default on) while the clipboard path asks for an extra acknowledgment instead: the user is pasting into their own chat, and the product stance is that the gate informs and confirms rather than polices; the only hard refusals are rules the user configured. AI output is marked in every report format (grey italics, generated blocks in src/core/report/layout.ts) under a legend, so a reader can always tell the machine's words from the reader's own.
Why. Licensed course material is the primary content; the risk is excerpts leaving under the wrong account or without the user realizing. A single tested chokepoint plus a type-level requirement is cheap insurance, and recording the account, the document hash and the question wording makes the decision auditable in the sidecar the user already owns.
Amendment (2026-09-02): the document-text context scope. The "only highlights and notes, never the PDF" rule is now the default rather than the only behavior. pdfCaseReview.ai.contextScope (default notes) adds a document-text scope (issue #22, v1) under which Summarize with AI and Copy Summary Prompt append the document's text to the prompt: extracted per page through the viewer, chunked with page-citation markers under a 400k-character budget with explicit truncation reporting (src/core/ai/documentText.ts). This supersedes the "no ai.sendFullText setting" line above: a scope enum whose wider position is actually implemented does not mislead the way a dead switch would. What has not changed is the shape of the gate. Every constraint holds: the one consent chokepoint (ensureAttestation in src/extension/ai/consentGate.ts) is still the only path, and the scope rides through it rather than around it; the consent record carries contextScope and needsReconsent (src/core/ai/consent.ts) treats any scope change, in either direction, as a fresh question, with pre-scope consents read as notes-only; only extracted text is ever sent, never the PDF file, so encryption and permissions are never touched; the dialog is honest about coverage (pages with extractable text, approximate words, image-only pages and scans named as excluded) instead of implying the whole document goes out; and the feature is always named "Document text", never "Full PDF", because extraction genuinely misses scans and image-only exhibits (owner decision). The staleness digest (src/core/ai/digest.ts) folds in the scope and the source hash for document-text runs while notes-only digests stay byte-identical, and the report provenance line adds "using document text". Add AI Page Context still sends notes only. The notes-plus-structure middle scope from issue #22 remains deferred with the sections work.
Amendment (2026-09-03): document-text is the default scope. pdfCaseReview.ai.contextScope now defaults to document-text; notes remains the opt-out that keeps the PDF's text on the machine. Owner decision: a summary must work on a freshly opened case before any highlights exist, and the consent dialog already states exactly what is sent (with coverage numbers) and requires an explicit yes, so the gate rather than the default is what protects the user. The gate itself is unchanged: the PDF file is still never sent, only extracted page text, and image-only pages and scans are still excluded. Alongside the default change, hasSummaryContent (src/core/ai/prompt.ts) makes Summarize with AI and Copy Summary Prompt refuse to run when there is nothing to send (no highlights and no notes under notes; additionally no extractable text under document-text), and SUMMARY_PROMPT_VERSION moved to 2, so summaries cached by earlier releases read as possibly out of date once. One correction to the amendment above: needsReconsent now treats a wider consent as covering narrower runs, so only widening the scope re-asks; switching back to notes does not.
Amendment (2026-09-08): accounts resolve per provider. The "Rules" paragraph above says a requiredAccount rule that selects an ai.accounts entry must select one for the active provider, and anything else is refused. The refusal stands, but the match is now made per provider rather than by comparing the entry's provider field with the setting: an id is unique per (id, provider) pair (accountKey in src/core/ai/accounts.ts), the same id may appear once for claude-cli and once for codex-cli, and a rule's use resolves to the entry for the active pdfCaseReview.ai.provider (resolveAccount). A matched rule whose id has no entry for the active provider is refused with a named fix (MissingAccountError in src/extension/ai/accountResolution.ts, whose toast opens the guided Add an AI Account... flow with provider and id preset); a duplicate pair warns and the first wins. Why: the owner's Claude plan hits its monthly usage limit, so switching to Codex for the rest of the month must be one action (the AI status bar item, src/extension/views/aiStatusBar.ts, or Choose AI Provider...) and must not require editing rules that encode a licensing obligation. The verified-identity-runs invariant is preserved because the gate and the spawn share one lookup: configDirFor in src/extension/ai/accountResolution.ts returns the directory the consent dialog probed, and the provider switch changes the consent record's provider, so the eligibility question is asked once more. A failed CLI run offers Switch AI Provider... and, when the failure text looks like a usage limit (src/core/ai/providerErrors.ts), says so instead of quoting the raw error.
ADR-0007: Large-PDF memory limits are settings; retainContextWhenHidden stays on by default
Decision. Three viewer settings govern memory on large documents: pdfCaseReview.viewer.maxCanvasPixels and viewer.maxImageSize (both 0 = keep the vendored PDF.js default, resource-scoped, applied when the document is reopened) and viewer.retainContextWhenHidden (default true, window-scoped). The retain flag is read once at provider registration because VS Code fixes webviewOptions there, so changing it needs a window reload and the setting description says so. The webview reads all annotations of a document with bounded concurrency (8 pages in flight) instead of strictly sequentially.
Why. Keeping the viewer alive across tab switches is the right default for the primary workflow (a case plus notes, switched constantly); its cost only matters for very large files, and the people who hit that are the ones who can flip a setting. Rendering limits belong to the user because the trade (memory versus sharpness at high zoom) depends on the machine. The defaults change nothing for existing documents.
Consequences. The 300-page fixture (pnpm fixtures) is CI-tested (test/integration/large.test.ts); the ~80 MB heavy fixture is opt-in (pnpm fixtures --heavy) and only feeds the manual pass in test/manual/memory-pass.md. pdf-lib inspection on open already skips files above 50 MB (INSPECT_LIMIT_BYTES).
ADR-0008: High-contrast rendering is baked at viewer build time; the host rebuilds on theme change
Decision. In high-contrast themes the viewer forces PDF.js page colors (forcePageColors plus pageColorsBackground / pageColorsForeground taken from the theme's editor colors). The host is the source of truth: it puts the active theme kind into the ViewerConfig, and a change of theme kind rebuilds every open viewer's HTML (the same path a palette change uses). There is no runtime theme message: the vendored PDF.js captures pageColors once at viewer construction, so flipping the option later would silently do nothing. Category identity is never color alone: tree icons get a thick contrasting ring in HC themes, report quotes name their category in the citation when the section heading is not enough, and the go-to flash has reduced-motion and forced-colors variants.
Why. Honesty over cleverness: a setTheme message that only works before the first render is a trap for the next maintainer. Rebuilding on a theme change is rare, visible and correct.
ADR-0009: A browser-level webview smoke (Playwright) beside the in-VS-Code suite
Decision. test/e2e drives the built webview bundle plus the vendored viewer in plain Chromium: a dependency-free static server over the repository root, pages assembled by the same pure buildViewerHtml (src/shared/viewerHtml.ts) the editor provider uses, acquireVsCodeApi stubbed to collect posted messages, and host-to-webview commands sent through window.postMessage exactly as VS Code delivers them. Chromium only, one Linux CI job. The in-VS-Code integration suite remains the primary harness; the smoke exists because the adapter over PDF.js internals is the project's riskiest seam (ADR-0003) and deserves a check without Electron in the loop.
Why this shape. Extracting the HTML assembly into a shared pure function means the harness cannot drift from what the provider ships, and gives the string-replacement contract (which prepare-pdfjs guards from the vendoring side) a consumer-side test. The stub is honest: the webview bootstrap only ever calls postMessage.
Consequences. pnpm test:e2e needs prepare-pdfjs, fixtures and build first. The harness omits the CSP meta; CSP behavior stays covered by the integration suite inside real webviews. Playwright is a devDependency only and ships nowhere.
ADR-0010: AI page context: pure candidate selection, per-page staleness, replace-by-page
Decision. Add AI Page Context (issue #29) writes a few AI sentences above a page's highlights in the report when the page has a dense but lightly-annotated cluster. The pure logic lives in src/core/ai/pageContext.ts: a page qualifies at ai.pageContext.minHighlights or more highlights with fewer than half carrying a note (pagesNeedingContext), the prompt builder requires the same branded Attestation as every other AI path (ADR-0006), and pageContextInputDigest hashes exactly what the prompt was built from, per page. Results are cached in the sidecar as aiPageContexts, sorted by page, one entry per page; regenerating a page replaces its entry. One consent dialog covers a whole batch of picked pages.
Why. The reader who highlights heavily but annotates lightly gets a report that is a wall of quotes; a couple of orienting sentences per busy page makes it skimmable without pretending to be the reader's own analysis. Reusing the ADR-0006 chokepoint means there is no second consent path to audit, and a per-page digest means editing page 4 never marks page 3's context stale.
Consequences. Reports interleave page contexts into the chronological stream and into per-page sections (pageContextBlocks in src/core/report/layout.ts), always above the page's own entries, in the same grey italics with per-block provenance. Because AI content can now appear before the summary section, the AI legend moved to the document head, emitted once when the report contains any AI content. The staleness fields (inputDigest, promptVersion) mirror aiSummary: a stale context is flagged in the report, never withheld.
ADR-0011: The prompt is reviewed in an editor tab before it is sent; run files live in global storage
Decision. With pdfCaseReview.ai.reviewPrompt on (the default), Summarize with AI writes the assembled prompt to <globalStorage>/ai/summary-<timestamp>.prompt.md, opens it as an ordinary text document and waits on a notification ("review the prompt (N words, about M tokens), edit it if you like, then send") with a Send button and Cancel. The provider receives the tab's text at the moment Send is pressed (TextDocument.getText(), unsaved edits included), never the original prompt object; the reply is written beside it as summary-<timestamp>.output.md and opened in the next column. Show AI Summary reuses the same folder for a summary-cached-<timestamp>.output.md view of the sidecar's aiSummary with a provenance header. The folder is pruned to the newest 20 files by the timestamp in the name. Pure helpers live in src/core/ai/promptReview.ts (promptText, promptStats, runFileName, filesToPrune, cachedSummaryDocument); the host side is src/extension/ai/summarize.ts. The token figure is a four-characters-per-token estimate, shown to give a sense of scale, not a bill. Alongside: ai.maxWords defaults to 500 (was 350) and is editable from the Configure hub (Summary Length..., src/extension/commands/configure.ts, written at the scope where the value is defined); pdfCaseReview.report.quoteMaxChars defaults to 0 so report quotes are whole unless the user asks for truncation; and SUMMARY_PROMPT_VERSION moved to 3 because the template now asks the model to avoid em-dashes.
Why. Owner decision: users should see where their tokens go and be able to adjust the prompt before it is spent. The consent dialog's coverage line is a count, not the text. An editor tab is the honest surface: it shows the exact bytes, it is editable with every VS Code affordance, and a file on disk survives a crash mid-run. Global storage rather than a folder beside the PDF keeps prompts (which carry the document text under the default scope) out of the user's directories and out of git. The staleness digest is still computed from the review model, not from the edited prompt, so editing the prompt never by itself marks the summary fresh or stale.
Consequences. The gate (ADR-0006) still runs before the tab opens, so a refused document never reaches a file. Copy Summary Prompt is unchanged: the clipboard is already its review surface. An edited prompt is sent verbatim, so a user can remove the word budget or the attestation line; that is their call, and the sidecar records provider, account and dates as before. Pruning by count means a prompt worth keeping past 20 runs should be copied elsewhere. Run files are a convenience: a failure to write them is logged and the summary is still cached.
Spike log
Results are recorded here as they land. Pass/fail criteria are in the maintainer plan (§14).
| # | Spike | Status | Result |
|---|---|---|---|
| 1 | HighlightEditor under the VS Code webview CSP with the category palette | pass (2026-08-30, Windows, VS Code 1.135, PDF.js 6.3.289) | test/integration/spike-highlight.test.ts: viewer loads with upstream's CSP (PDF.js's own CSP meta stripped at rewrite time, zero patches), annotationEditorMode 0 keeps the floating button, highlightEditorColors accepts our id=#HEX palette. Findings: state event is editingstateschanged (not annotationeditorstateschanged); annotationeditorlayerrendered is gone; pagerendered / editorsrendered cover it; the UI manager arrives via the annotationeditoruimanager event. macOS/Linux pending (CI). |
| 1b | Highlights survive the tab being hidden/shown (retainContextWhenHidden) | pass (automated + manual, 2026-08-30) | Integration test: webview not rebuilt (loads unchanged), editors still in the UI manager, and drawn (rendered ≥ editors) after hide/show. Manual F5 run confirmed (3 highlight(s), 3 drawn, no change across a tab switch). An earlier "highlights vanished" report was the Ctrl+Z undo test, not a redraw bug. The Output channel logs N highlight(s), M drawn at info level to distinguish "removed" from "not drawn" in future reports. UX findings from the same session: PDF.js's highlight params dropdown (color/thickness/show-all) stays open and covers the page; close it or pick colors from our sidebar/keybindings instead (M1); "keep highlighting" already exists: the highlighter tool (pen icon, HIGHLIGHT mode) turns every selection into a highlight on pointer-up (#onSelectEnd("main_toolbar")), and creating one via the floating button switches the viewer into that mode. |
| 2 | Text capture from selection vs. quad intersection | pass (M1 phase D, test/integration/spike-text.test.ts) | Primary text comes from the editor's aria-label (PDF.js writes its private #text there on render), with the selectionchange pairing as fallback. The cross-check textInQuads (src/core/text/quadText.ts) intersects the quads with page.getTextContent() items (50% vertical coverage, proportional slice for partly covered items, hasEOL becomes a line break so hyphenation can be undone) and is reported as quadText; it is also the text source for injected and free highlights. The test requires agreement after normalization on at least 90% of ten samples across three pages. |
| 3 | Change detection + serialize round-trip | pass for create / recolor / delete / undo / redo (test/integration/reload.test.ts) | serialize() geometry comes back as Float32Array (flattened by toPlain), quadPoints in 8s, color as [r,g,b]; for file-backed editors serialize() returns null until edited, then carries id = the annotation id. Editors backing annotations are addressed by annotationElementId, not uiManager.getEditor(id). Delete via setSelected + uiManager.delete() is undoable; undo re-adds the same editor instance, so its id comes back and the host restores note and timestamps from a tombstone (ADR-0004). Text highlights are not draggable in PDF.js 6.3 (_isDraggable = false), so "move" applies to free highlights only. A deserialize round trip shifts geometry by well under 0.05 pt; the reconcile compares with that tolerance. |
| 4 | Re-loading highlights from a PDF written by pdf-lib | pass (2026-08-30, test/integration/spike-embed.test.ts) | src/core/pdfExport/embedHighlights.ts writes /Highlight + /Popup annots with /NM = our UUID, /Subj = category, /Contents = note and a /PdfCaseReview true marker; re-embedding strips ours and leaves foreign annots alone. PDF.js reports them on load with exactly the ids pdf-lib predicted (<objectNumber>R), they become editable editors in highlight mode, a recolor serializes with that id, and PDF.js's own saveDocument() keeps them and applies the edit. Design consequence: PDF.js never surfaces /NM, so the sidecar stores pdfjsId per highlight (refreshed on every sync; a full pdf-lib save keeps object numbers stable). Also found: the workspace file watcher can deliver a write that predates the document being opened → the provider now reloads only when the SHA-256 of the file changes. Shipped as the dual-write sync in M1 (src/extension/pdfSync/pdfSync.ts, test/integration/pdf-sync.test.ts): sidecar first, PDF rewritten in memory and written atomically, the document hash set before the write so the self-write never reloads the viewer. |
| 4b | Encrypted (publisher-style) PDF | pass | Fixture test/fixtures/static/encrypted-case.pdf (AES-256, R6, owner password, no-modify). Opens with the empty user password, accepts highlights; pdf-lib refuses it (ProtectedPdfError; note pdf-lib's ES5 build breaks instanceof EncryptedPDFError, match the message); PDF.js saveDocument() produces a valid incremental update that stays encrypted. So protected files have a viable dual-write path through PDF.js's writer (category color + note only, no /Subj); scheduled for 1.1 per ADR-0002, sidecar-only in 1.0. |
| 4c | Drawing sidecar-only highlights in the viewer (protected PDF, embedding off, unsaved) | pass (M1 phase D, test/integration/reload.test.ts) | Strategy 1 works: in NONE mode, once a page's AnnotationEditorLayer exists (uiManager.getLayer(pageIndex) after pagerendered), layer.deserialize({ annotationType: 9, color: [r,g,b], opacity, rect, rotation, quadPoints }) followed by layer.add(editor) draws the highlight and makes it an ordinary editor; the adapter tags it with the sidecar id so the host binds it without a new uuid. The editor's onceAdded is neutralized on the instance first: injected highlights must not become undo commands (a page injected after an edit would otherwise sit on top of the stack, and Ctrl+Z removed it instead of undoing the edit, which is what the macOS CI runs caught) and must not take focus; the override also holds when PDF.js re-adds every editor of a recycled page. Pending injections run on annotationeditorlayerrendered, since pagerendered fires before the page's editor layer exists. The same path works on the encrypted fixture. |
| 5 | Report rendering (docx + pdfmake) in the host bundle | pass (2026-08-30, test/integration/spike-report.test.ts) | src/core/report/: buildReportModel → layoutReport (block IR; notes parsed with marked) → renderMarkdown / renderDocx (docx 9, Packer.toBlob so it also works in a browser) / renderPdf (pdfmake 0.3 browser build + Roboto VFS via addVirtualFileSystem). tsup splitting: true works for the CJS host bundle: activation loads a 20 KB extension.js; docx (0.7 MB) and pdfmake+fonts (3.7 MB unminified) are lazy chunks. All three formats render in the host in ~1.6 s total (first call, chunks cold); VSIX 4.7 MB. Sample outputs: pdfCaseReview.debug.renderSampleReport writes sample-report.{md,docx,pdf} from the synthetic SAMPLE_REPORT_INPUT. |
| 6 | Reading the logged-in account from Claude Code / Codex | pass on Windows (2026-08-30); macOS/Linux to confirm | Claude Code: claude auth status prints JSON with loggedIn, authMethod, email, orgId, orgName, subscriptionType; so identity needs no file parsing (the same fields also sit in ~/.claude.json → oauthAccount.emailAddress / organizationName as a fallback; credentials themselves are elsewhere). Codex: codex login status only says "Logged in using ChatGPT"; the email is the email claim of tokens.id_token in $CODEX_HOME/auth.json (default ~/.codex), with chatgpt_plan_type and organization titles under the https://api.openai.com/auth claim; an OPENAI_API_KEY login has no email → unverifiable. Pure parsers with tests: src/core/ai/identity.ts (parseClaudeAuthStatus, parseCodexAuthJson, JWT payload decoded without verification, display only). The host side (spawning the CLIs, reading the file) is desktop-only code for M2. |