SPIKE: Code Tour provider as the template for the Guide provider
Date: 2026-07-02
Status: Verified against code. Feeds spec work off adr/decisions/006-guided-review-first-class-feature-20260702-192821.md.
Question
ADR 006 says the guide agent-job provider should follow the Tour pattern
(packages/server/tour/) "rather than inventing a new lifecycle." This spike
documents that pattern end to end, with exact file:line refs, so the guide
provider's spec can cite a checklist instead of re-deriving it.
1. packages/server/tour/tour-review.ts — the provider module
One file owns: system prompt, JSON schema, user-message builder, CLI command builders for both engines, output parsers, and a session object that wraps all of it behind a small interface the jobs engine calls into.
-
TOUR_SCHEMA_JSON(:20-84) — a hand-built JSON Schema string (not derived from the TS types),additionalProperties: falseeverywhere, passed verbatim to both CLIs' schema flags. Shape:title, greeting, intent, before, after, key_takeaways[], stops[], qa_checklist[]. Eachstophastitle/gist/detail/transition/anchors[]; eachanchorhasfile/line/end_line/hunk/label—hunkis a full unified-diff string the model must fabricate from the patch it read, not a pointer into the real patch. This is a deliberate schema choice worth flagging (see §7). -
TOUR_REVIEW_PROMPT(:86-287) — a long, carefully tuned system prompt: identity/tone rules (conversational, no em-dashes, no emoji), an "Output structure" section walking through every schema field with worked examples, a "Pipeline" section (read diff → read CLAUDE.md/README → read commits/PR → group into stops → order by reading flow → emit JSON), and a "Hard constraints" section restating the non-negotiables (non-empty hunks, no fabricated line numbers, one-sentence gist, etc.). ADR 006 explicitly says the guide prompt "gets the same level of care" — this is the bar. -
buildTourUserMessage(:289-338) — dispatches on context shape: PR full-stack, PR with local worktree access, PR without local access, workspace (multi-repo), or local diff (viagetLocalDiffInstructionfrom../agent-review-message). Each branch produces a short instruction + (sometimes) the inlinedpatchin a fenced diff block.buildWorkspaceTourUserMessage(:340-353) is the multi-repo variant. -
buildTourClaudeCommand(:360-404) — exact argv for the Claude engine:claude -p --permission-mode dontAsk --output-format stream-json --verbose --json-schema <TOUR_SCHEMA_JSON> --no-session-persistence --model <model> [--effort <effort>] --tools Agent,Bash,Read,Glob,Grep --allowedTools <comma list: Agent,Read,Glob,Grep,git/jj/gh/glab read-only subcommands,wc> --disallowedTools <Edit,Write,NotebookEdit,WebFetch,WebSearch,interpreters,curl/wget>Prompt goes over stdin (
stdinPrompt), not argv — avoids argv length/escaping limits.captureStdout: trueis required upstream so the jobs engine buffers stdout forparseTourStreamOutput. -
buildTourCodexCommand(:423-449) — Codex path writes the JSON schema to a materialized file (ensureTourSchemaFile, :410-417, memoized so it's written once per process) because Codex takes--output-schema <path>, not an inline schema. Global flags (-m,-c model_reasoning_effort=,-c service_tier=fast) must precede theexecsubcommand. Output goes to a temp file via-o <outputPath>(generateTourOutputPath, :419-421, usesos.tmpdir()+ uuid), not stdout —--full-auto --ephemeral -C <cwd> <prompt>. -
Parsers:
parseTourStreamOutput(:451-475) — walks stdout lines backwards, parses each as JSON, looks for{ type: "result" }, bails onis_error, and — critically — rejects a parse ifstopsis missing/empty (comment: "A tour with no stops isn't a tour"). Returnsnullon any failure; never throws.parseTourFileOutput(:477-491) — same empty-stops guard for the Codex file-output path; always attemptsunlink(outputPath)in both the success and catch paths (temp file cleanup happens here, not in the jobs engine).TOUR_EMPTY_OUTPUT_ERROR(:18) — the calm string surfaced to the job when parsing succeeds structurally (exit 0) but yieldsnull.
-
createTourSession()(:542-604) — the object review.ts holds one instance of (const tour = createTourSession()at review.ts:206). Two in-memoryMaps (tourResults: Map<jobId, CodeTourOutput>,tourChecklists: Map<jobId, boolean[]>) — no persistence, dies with the server process. Exposes:buildCommand({ cwd, patch, diffType, options, prMetadata, config })— readsconfig.engine(default"claude"),config.model(falls back to"sonnet"for Claude, blank for Codex — comment explains why:"sonnet"is a Claude-only model id and must not leak to Codex),config.effort,config.reasoningEffort,config.fastMode. Builds the user message once, prependsTOUR_REVIEW_PROMPT, then branches engine → returns theTourSessionBuildCommandResultshape (command/outputPath/stdinPrompt/cwd/ label/prompt/engine/model/effort/reasoningEffort/fastMode).onJobComplete({ job, meta })— picks the parser byjob.engine === "codex" ? outputPath : stdout, stores the result intourResults, and returns aTourSessionJobSummary(correctness: "Tour Generated",explanation: "<n> stops, <m> QA items",confidence: 1.0). Returns{ summary: null }on parse failure — no exception, no partial write.getTour(jobId)— merges the storedCodeTourOutputwith its checklist (defaulting to[]), returnsnullif the job's result was never stored.saveChecklist(jobId, checked)— blind overwrite, no validation of length againstqa_checklist.length.
2. Jobs-engine integration in packages/server/review.ts
-
const tour = createTourSession();— review.ts:206. One instance per review server process/session (matches ADR 006's "guide persistence beyond server memory" being explicitly deferred). -
createAgentJobHandler({ mode: "review", ..., buildCommand, onJobComplete })— review.ts:616 opens the whole handler; the outerbuildCommandclosure (:621-752) does state snapshotting before anyawait(comment at :622-624: "Snapshot ALL launch-relevant state before any await: waiting out the checkout warmup below yields to other requests"). It snapshotsprMetadata,currentPatch,currentDiffType,currentBase,currentPRDiffScopeintolaunch*locals up front, resolves the requested review profile, then (only relevant to PR mode) awaitsensurePRLocalCwd(launchMetadata)whenoptions.worktreePoolis set — throwing a user-facing error if the PR checkout isn't ready yet ("Local PR checkout unavailable... Retry shortly") rather than silently running in the wrong directory. It then buildsdiffContext(mode/base/worktreePath, ornullworktreePath for workspace mode, orundefinedentirely in PR mode sinceprMetadatacovers it) — this snapshot rides on every provider's job, tour included.The
if (provider === "tour")branch (:690-700) is the first check in the outer switch (before codex/claude/marker branches), and just forwards totour.buildCommand({ cwd, patch: launchPatch, diffType: launchDiffType, options: userMessageOptions, prMetadata: launchMetadata, config }), then splicesprUrl/diffScope/diffContext/reviewProfileId/reviewProfileLabelonto whatevertour.buildCommandreturned. Tour is the only provider that delegates its wholebuildCommandbody to an external module — codex/ claude/marker branches inline their own logic in review.ts. -
onJobComplete(:754-915) is one big function with a per-provider branch at the end. Theif (job.provider === "tour")branch (:901-914) callstour.onJobComplete({ job, meta }). Ifsummarycomes back it's assigned tojob.summary(same field every provider uses — codex/claude/ marker setcorrectness/explanation/confidenceinline, tour's is just{correctness: "Tour Generated", explanation: "<n> stops...", confidence: 1.0}). Ifsummaryisnull, the branch flipsjob.status = "failed"and setsjob.error = TOUR_EMPTY_OUTPUT_ERROR— explicit comment: "the client doesn't auto-open a successful-looking card that 404s on/api/tour/:id." This is the fail-closed precedent the marker-engine branch above it explicitly cites in its own comment (:854). -
Routes — review.ts:1015-1034, registered inline in the
fetch()handler before the generic/api/diffroute:GET /api/tour/:jobId(:1016-1021) — regex match,tour.getTour(jobId), 404{ error: "Tour not found" }on miss, else the fullCodeTourOutput & { checklist }as JSON.PUT /api/tour/:jobId/checklist(:1024-1034) — regex match, parses{ checked: boolean[] }, callstour.saveChecklistonly ifcheckedis an array, returns{ ok: true }or 400 on bad JSON. No validation thatchecked.lengthmatches the tour'sqa_checklist.length.
3. packages/server/agent-jobs.ts — generic jobs engine touch points
-
SERVER_BUILT_PROVIDERS(:57-63) —Set(["claude", "codex", "tour", "cursor", "opencode"]). Membership here means: client-suppliedcommandargv is discarded (:568-571) and anull/missingbuildCommandresult is a hard error (:561-567), never a silent fallback to client argv. A guide provider must be added to this set or the/api/agents/jobs POSThandler will accept and spawn arbitrary client-supplied commands for it. -
Capability entry (:158-162) —
{ id: "tour", name: "Code Tour", available: !!Bun.which("claude") || !!Bun.which("codex") }. Availability is OR'd across both engines the provider can use (unlike codex/claude capabilities which each gate on their own binary). A guide entry follows the same OR shape if it also supports both engines. -
AgentJobHandlerOptions.buildCommandreturn shape (:81-110) — the full set of optional fields a provider's builder can populate:command/outputPath/captureStdout/stdinPrompt/cwd/label/prompt/engine/ model/effort/reasoningEffort/fastMode/prUrl/diffScope/diffContext/ reviewProfileId/reviewProfileLabel. Tour populates all exceptreviewProfileId/Labelare passed through from the outer review.ts wrapper, not fromtour.buildCommanditself (tour.ts doesn't know about review profiles). -
spawnJob(:202-412) — generic process lifecycle. Notable for a new provider:- stdout capture is opt-in via
captureStdout(:237, :244) — Codex-style providers that write to a file instead don't need it; Claude-engine tour jobs do set it (mirrors the Claude review path). - The stdout-formatting
emitLogLineclosure (:310-333) has an explicit tour carve-out: "Tour jobs with the Claude engine also stream Claude JSONL, so key off engine too" (:312-314) — checksprovider === "claude" || spawnOptions?.engine === "claude"to route throughformatClaudeLogEvent. A guide provider using the Claude engine needs the identical|| spawnOptions?.engine === "claude"check added, or its live log stream will render raw JSONL instead of formatted text. - Exit handling (:354-398) calls
onJobCompleteonly onexitCode === 0; tour's fail-closed (job.status = "failed"on empty output) happens insideonJobComplete, not here — the generic engine still marks the job"done"first (:364) and lets the provider'sonJobCompletedowngrade it after ingestion runs synchronously before thejob:completedbroadcast (:395).
- stdout capture is opt-in via
-
POST /api/agents/jobsbody validation (:520-653) —KNOWN_JOB_FIELDS(:527-531) is["provider", "command", "label", "engine", "model", "reasoningEffort", "effort", "fastMode", "reviewProfileId"]; any other key in the request body 400s. If the guide launch needs a provider-specific field not in this list (unlikely, given tour needed none), it must be added here too.
4. Shared types
-
packages/shared/tour.ts(61 lines, full file read) — pure interfaces, no logic:TourDiffAnchor,TourKeyTakeaway,TourStop,TourQAItem,CodeTourOutput(the agent's raw output), andCodeTourData = CodeTourOutput & { checklist: boolean[] }(the UI-side shape after checklist is merged in). A guide equivalent (GuidePage,GuideDiffSlice?,GuideOutput,GuideData = GuideOutput & { reviewed: boolean[] }) is the direct parallel. -
packages/shared/agent-jobs.ts(156 lines, full file read) — the provider-agnostic job model. Relevant fields onAgentJobInfo(:31-82):provider: string(free-form id, "tour" today),engine?: string("set when provider is 'tour'" per the doc comment at :39 — this comment will need updating to mention guide too),model/effort/reasoningEffort/ fastMode(all provider-agnostic, reused verbatim by tour).sourceisjobSource(id) = "agent-" + id.slice(0,8)(:141-143) — used to tag annotations created by a job; a guide provider likely doesn't ingest annotations directly (guide pages aren't findings), sosourcemay go unused for it, which is fine — it's still assigned uniformly byspawnJob.markJobReviewFailed(:153-156) is the Tour-precedent fail-closed helper, explicitly commented as reusable ("Code Tour precedent") — the marker-engine path in review.ts inlines the same pattern by hand instead of calling this helper (worth normalizing when adding guide, not required).
5. Client side
-
packages/review-editor/hooks/tour/useTourData.ts(full file, 107 lines) —useTourData(jobId)hook: fetches/api/tour/:jobIdon mount (short-circuits toDEMO_TOURwhenjobId === DEMO_TOUR_ID, :29-35), derives initialcheckedfromdata.checklistor an all-false array sized toqa_checklist.length(:44), and debounce-persists checklist toggles (500ms, :57-72) plus a synchronouskeepalive: trueflush on unmount (:89-103) so a tab-close doesn't drop the last toggle. A guide "Reviewed" checkbox hook is a structural clone of this, sized to page count instead of QA-item count, hitting aPUT /api/guide/:jobId/reviewed-shaped endpoint. -
demoTour.ts— dev-mode fixture (DEMO_TOUR_ID = 'demo-tour',DEMO_TOUR: CodeTourData) rendered without a live agent job, toggled by Cmd/Ctrl+Shift+T in dev (App.tsx :766-777). Useful precedent for iterating on the guide takeover screen's visual design before the agent/schema exist. -
App.tsxdialog-lifecycle wiring (allimport.meta.env.DEV-gated bits aside):tourDialogJobIdstate (:352-353) +handleOpenTour(:762-764) opens it.- Auto-open effect (:779-792) — scans
agentJobs.jobson every change, and for anyjob.provider === 'tour' && job.status === 'done'not already seen (tracked via aSetref, :780, so it only fires once per job), callssetTourDialogJobId(job.id). Note this does not checkjob.status === 'failed'— a failed tour job (including theTOUR_EMPTY_OUTPUT_ERRORcase from §2) never auto-opens anything, it just surfaces via the generic agent-jobs status UI. A guide provider's auto-open (if the ADR's takeover screen should also auto-open on completion) should copy this same done-only, once-per-job-id gating. <TourDialog jobId={tourDialogJobId} onClose={...} />(:3125) rendered unconditionally at the App root, alongside the dev-only demo-tour toggle button (:3127-3136). ADR 006 wants a screen takeover (hiding file tree + center dock, not an overlay dialog) — structurally this means the guide surface is a sibling toTourDialogin the render tree, but it needs to reach into the layout state that currently lives inside the dock machinery to actually hide the tree/dock rather than floating over them. That plumbing does not exist yet for Tour (TourDialogis a pure overlay, z-stacked over everything, not a layout replacement) — this is a real gap, not just a reuse point.
-
packages/ui/components/AgentsTab.tsx—buildTourLaunch(:815-823):const buildTourLaunch = (): LaunchParams => ({ provider: 'tour', label: 'Code Tour', engine: tourEngine, model: tourEngine === 'claude' ? tourClaudeModel : tourCodexModel, ...(tourEngine === 'claude' ? { effort: tourClaudeEffort } : { reasoningEffort: tourCodexReasoning, ...(tourCodexFast && { fastMode: true }) }), });Fed into the generic launch button at :837 (
selectedMode === 'review' ? buildReviewLaunch(reviewEngine) : buildTourLaunch()), which POSTs to/api/agents/jobs. Capability gating:tourAvailable(:648) readscapabilities.providers.find(id === 'tour').available;availableModes(:685-690) only offers'tour'as a mode whentourAvailable && availableEngines.length > 0(i.e., Claude or Codex CLI present). The mode-reconciliation effect (:702-724) snaps back to an available mode/engine if capabilities change under the user (e.g. a CLI gets uninstalled mid-session). All ofAgentMode/AgentEngineunion types, the engine/model/effort state variables (tourEngine,tourClaudeModel, etc., via some settings-persistence hook above :590), and this whole gating block need a'guide'sibling — likely copy-modify given how tightlytour-specific state threads through this ~1200-line component today.
6. Pi extension — confirmed, tour IS mirrored, via vendoring not by hand
CLAUDE.md's "both server implementations must be updated" rule is satisfied
for Tour not by a hand-maintained duplicate of tour-review.ts, but by a
build-time vendoring step: apps/pi-extension/vendor.sh (source-of-truth
comment: "Vendor shared modules into generated/ for Pi extension. Single
source of truth"). Relevant excerpt:
# packages/shared/*.ts copied verbatim (with a "@generated" header) —
# "tour" and "agent-jobs" are both in this list:
for f in ... external-annotation agent-jobs ... tour ...; do
cp "../../packages/shared/$f.ts" -> "generated/$f.ts" (with header)
done
# packages/server/tour/tour-review.ts copied with sed-rewritten imports
# (parent-relative "../vcs", "../pr", "../agent-review-message" and the
# "@plannotator/shared/tour" alias all get remapped to the flat generated/
# layout):
for f in tour-review; do
cat "../../packages/server/tour/$f.ts" | sed \
's|from "\.\./vcs"|from "./review-core.js"|; ...' \
> "generated/$f.ts"
done
So apps/pi-extension/generated/tour-review.ts and
apps/pi-extension/generated/tour.ts are exact copies of the Bun-side
source (modulo import paths), regenerated by vendor.sh on every Pi build
(package.json build script runs bash vendor.sh). This means:
TOUR_REVIEW_PROMPT,TOUR_SCHEMA_JSON, the CLI command builders, and the parsers are never hand-duplicated for Pi — they're the same file, copy-pasted by a script, not re-implemented.- What is hand-written twice is the HTTP-layer glue:
apps/pi-extension/server/agent-jobs.ts(imports from../generated/agent-jobs.js, has its ownSERVER_BUILT_PROVIDERSSet including"tour"at :49, its own capability entry{ id: "tour", ... available: whichCmd("claude") || whichCmd("codex") }at :144) andapps/pi-extension/server/serverReview.ts(importscreateTourSession/TOUR_EMPTY_OUTPUT_ERRORfrom../generated/tour-review.jsat :103, instantiates its ownconst tour = createTourSession()at :590, has its ownprovider === "tour"branches inbuildCommand/onJobCompleteat :710-711/:916-917 mirroring review.ts almost line-for-line but againstnode:http'sreq/resinstead ofRequest/Response, and its ownGET /api/tour/:jobId+PUT /api/tour/:jobId/checklistroutes at :959-977).
Implication for the guide provider: the schema/prompt/parser module
(analogous to tour-review.ts) gets Pi support "for free" by adding its
filename to the two for f in ... loops in vendor.sh (the shared-types loop
for packages/shared/guide.ts, and the server-tour-style loop for
packages/server/guide/guide-review.ts, with whatever import-path sed
rewrites its actual imports need — check what it imports from
agent-review-message.ts/vcs.ts/pr.ts first). The HTTP-layer wiring
(SERVER_BUILT_PROVIDERS, capability entry, buildCommand/onJobComplete
branches, /api/guide/:jobId + /api/guide/:jobId/reviewed routes) must
still be hand-written a second time in apps/pi-extension/server/agent- jobs.ts and apps/pi-extension/server/serverReview.ts, mirroring
packages/server/agent-jobs.ts / packages/server/review.ts — there is no
shared HTTP layer between Bun and Node here, by design (per CLAUDE.md's
"Server Runtimes" section: Pi "mirrors the Bun server's API but uses
node:http primitives instead of Bun's Request/Response APIs").
7. Implications for a guide provider
Checklist — every file/integration point to touch
New module (mirrors packages/server/tour/tour-review.ts):
-
packages/shared/guide.ts—GuidePage/GuideDiffSlice/GuideOutput/GuideDatatypes (mirrorspackages/shared/tour.ts). -
packages/server/guide/guide-review.ts—GUIDE_SCHEMA_JSON,GUIDE_REVIEW_PROMPT,buildGuideUserMessage(can likely reusegetLocalDiffInstruction/buildWorkspacePromptContextLinesfromagent-review-message.tsverbatim),buildGuideClaudeCommand/buildGuideCodexCommand(near-identical argv to tour's, same allow/disallow tool lists),parseGuideStreamOutput/parseGuideFileOutput(same empty-check discipline: no pages = invalid),GUIDE_EMPTY_OUTPUT_ERROR,createGuideSession()withguideResults/guideReviewedmaps and the samebuildCommand/onJobComplete/getGuide/saveReviewedinterface shape.
Bun server wiring (packages/server/review.ts):
- Instantiate
const guide = createGuideSession();alongside the tour one. -
if (provider === "guide")branch in thebuildCommandclosure, forwarding the same snapshottedlaunchPatch/launchDiffType/ userMessageOptions/launchMetadata/config. -
if (job.provider === "guide")branch inonJobComplete, same fail-closed pattern (empty output →job.status = "failed"). -
GET /api/guide/:jobIdandPUT /api/guide/:jobId/reviewedroutes (mirrors :1016-1034), documented in CLAUDE.md's Review Server endpoint table.
Generic jobs engine (packages/server/agent-jobs.ts):
- Add
"guide"toSERVER_BUILT_PROVIDERS(:57-63). - Add a capabilities entry (same OR-across-engines availability rule as tour, :158-162).
- Extend the
emitLogLineClaude-engine check (:312-314) to also coverspawnOptions?.engine === "claude"whenprovider === "guide"— i.e. change the condition to check engine regardless of provider, or explicitly addprovider === "guide"alongside"claude". (Cleanest fix: key entirely offspawnOptions?.engine === "claude", drop the provider check — verify no other provider needs the old behavior first.) - If a guide-specific config field is needed, add it to
KNOWN_JOB_FIELDS(:527-531).
Shared types (packages/shared/agent-jobs.ts):
- Update the doc comment on
AgentJobInfo.engine/model(:38-42) to mention "guide" alongside "tour" (comment currently hardcodes "tour").
Pi extension:
- Add
guide-review(or whatever the file's named) to the sed-rewrite loop inapps/pi-extension/vendor.sh, andguideto thepackages/shared/*copy loop. - Hand-port the same
buildCommand/onJobCompletebranches and/api/guide/*routes intoapps/pi-extension/server/agent-jobs.tsandapps/pi-extension/server/serverReview.ts— no shortcut here, this is manual duplication by design.
Client:
-
packages/review-editor/hooks/guide/useGuideData.ts— clone ofuseTourData.ts,reviewed: boolean[]sized to page count instead ofchecklistsized to QA-item count. - Guide takeover screen component — not a
TourDialog-style overlay; per ADR 006 it must hide the file tree + center dock, which means hooking into whatever layout/dock-visibility statepackages/review-editor/dock/exposes (no existing precedent — Tour never did this). -
App.tsx— state + open/close handlers for the takeover screen, analogous totourDialogJobId/handleOpenTour/the auto-open effect (:352-353, :762-764, :779-792) but gated onjob.provider === 'guide'. Decide deliberately whether failed-job auto-open should differ from Tour's silent no-op. -
packages/ui/components/AgentsTab.tsx—buildGuideLaunchalongsidebuildTourLaunch(:815-823), a'guide'value in whateverAgentModeunion backsselectedMode, its own engine/model/effort state variables, and inclusion in theavailableModes/capability-gating logic (:646-724). This file is already the densest single touch point for Tour; expect the same for guide. - Marketing/docs: if guide gets a keyboard shortcut (entry point is a
header badge per ADR 006), define it in
packages/ui/shortcuts/code-review/following the existingtourDialog.shortcuts.tsprecedent, wired viauseShortcutScopeat the call site.
Awkwardness in the Tour pattern — do not copy
- Anchors are fabricated diff text, not real patch pointers. The schema's
hunkfield (tour-review.ts :56, :210-223) asks the model to retype a unified diff hunk from what it read, rather than referencing file+line-range into the actual patch the server already holds. This is whyTourStopCard.tsxrenders anchors viaDiffHunkPreview.tsx, which feeds the anchor's fabricatedhunkstring into@pierre/diffs'FileDiff(getSingularPatch(anchor.hunk)) — a real diff-rendering component, but fed synthetic text, with no annotation wiring (nouseAnnotationToolbar, noCodeAnnotationlist hookup). ADR 006's "annotation parity is a hard constraint" directly rules this out: guide diff slices must reference real coordinates in the actual review patch (file path + line range, resolved against the live diff already loaded inApp.tsx/ReviewStateContext) so the exact sameFileDiff/CodeView+useAnnotationToolbar+CodeAnnotation+/api/feedbackmachinery used elsewhere in the app can render them, live and annotatable, instead of a second read-only preview component. This is also the ADR's explicitly deferred question ("the anchoring granularity of guide diff slices back into the real patch — file-level vs. hunk-level") — resolve it in favor of anchoring into the real patch, not schema-level hunk fabrication. - In-memory-only storage with no eviction.
tourResults/tourChecklistsare plainMaps that live for the process lifetime with no cap, no TTL, no persistence. ADR 006 flags this as deferred for guide too ("guide persistence beyond server memory... may warrant durability") — worth deciding up front whether guide should persist to~/.plannotator/given guides are more expensive to regenerate than a tour and more likely to be revisited across sessions. - Checklist/reviewed-state save has no shape validation.
saveChecklist(tour-review.ts :600-602) and thePUT .../checklistroute (review.ts :1024-1034) accept anyboolean[]with no length check against the actualqa_checklist/page count. Harmless for tour (worst case: checklist UI shows fewer/more boxes than array entries), but worth a bounds check for guide if page count drives layout/collapse state more centrally. AgentsTab.tsxstate sprawl. Tour's engine/model/effort selection lives as ~10 discreteuseState-like variables threaded through one already-large component (:590-618, :646-738, :815-823, :1010-1036). Copy-modifying this for guide is the path of least resistance but compounds the file's size; if guide's settings surface is meant to be simpler (ADR 006 says "same engine/model/effort settings," so maybe not simplifiable), at least group the per-provider state behind a small struct/reducer instead of adding another ten loose variables.