diff --git a/.braid/snapshot.jsonl b/.braid/snapshot.jsonl
index f7819b2b9..bc757d9b3 100644
--- a/.braid/snapshot.jsonl
+++ b/.braid/snapshot.jsonl
@@ -153,6 +153,7 @@
{"id":"bd-3y7ul","title":"CI on main broken: Instant::now() panics on WASM in pass_one","description":"After bd-m7x9s (Parallelize Pass-1 via rayon, 665bbb34), CI is failing on origin/main. hub-client wasm tests (assetManifestProject, themeFingerprint, customNodeWireFormatProject) panic with 'PanicError: time not implemented on this platform' from `std::time::Instant::now()` in `ProjectPipeline::pass_one` (orchestrator.rs:847). The Instant::now() and the subsequent .elapsed() feed the `perf.pass1` gauge, but they always run — not gated for WASM.\n\nFix: route monotonic-clock access through SystemRuntime with a WASM shim using `performance.now()`. Add `monotonic_now_nanos() -> u64` to the trait; native default uses a process-start Instant; WasmRuntime overrides with js_sys/performance.\n\nFailing run: https://github.com/quarto-dev/q2/actions/runs/26310309964/job/77457143629","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-05-22T21:03:37.847050Z","created_by":"cscheid","updated_at":"2026-05-22T21:16:17.323985Z","closed_at":"2026-05-22T21:16:17.323861Z","close_reason":"Fixed: SystemRuntime::monotonic_now_nanos shim (performance.now on WASM) + pass_one_dispatch_async to avoid pollster::block_on on WASM. Native + WASM tests green, full cargo xtask verify passes."}
{"id":"bd-3zp3z4jx","title":"Link URL corrupted on write-back: a new/edited link in a multi-link paragraph gets an adjacent link's URL","description":"Repro (found via rich-text editor, but the bug is in the shared text-channel write-back, NOT the editor): editing a paragraph that ends up with two links commits correct markdown but persists the wrong URL.\n\nTS commit (verified via console log) sends:\n dest = {t:0, r:[75,208], d:0}\n newText = 'This is an [ordinary](https://example.org/ord) paragraph with **bold text**, *italic text*, some `inline code`, and a [hyperlink](https://quarto.org) to click.'\n\nAfter commitTextEdit -> parse_qmd_content(newText) -> apply_node_edit -> incremental_write, the file becomes:\n 'This is an [ordinary](https://quarto.org) paragraph ... and a [hyperlink](https://quarto.org) ...'\n\ni.e. the NEW link 'ordinary' gets the OTHER link's URL (quarto.org) instead of its own (example.org/ord). The serializer/round-trip is fine (a TS round-trip with two distinct links keeps both URLs). The corruption is in crates/pampa/src/apply_node_edit.rs + incremental writer / reconcile, likely in Link target source-info (targetS / url pool) handling when a paragraph is re-serialized after a link is added/changed. Because the text channel (commitTextEdit) is shared with the monospaced textarea editor, this affects link editing there too — suspected pre-existing. Single-link or no-link paragraph edits are unaffected (round-trips clean in the rich-text suite).","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-06-23T23:01:30.815805Z","created_by":"Carlos Scheidegger","updated_at":"2026-06-24T15:14:31.024188Z","closed_at":"2026-06-24T15:14:31.024188Z","close_reason":"Merged as PR quarto-dev/q2#336","labels":["write-back"],"dependencies":{"bd-sjb4pzx8:discovered-from":{"depends_on_id":"bd-sjb4pzx8","type":"discovered-from","created_at":"2026-06-23T23:01:30.815805Z","created_by":"Carlos Scheidegger"}},"comments":{"c-hw3md4bm":{"id":"c-hw3md4bm","author":"Carlos Scheidegger","created_at":"2026-06-23T23:36:28.965290Z","text":"Root-caused + fixed on branch braid/bd-3zp3z4jx-link-url-corrupted-write (off origin/main 19aff019), commit be7a6d20. Root cause: reconciler matched container inlines by type discriminant only, so a new Link matched an old Link regardless of target; the inline splice then copied the old link's ](url) delimiter verbatim. Fix: require non-child identity (Link/Image target+attr, Span attr) to match before recursing; else UseAfter re-serializes via the qmd writer. TDD tests added (failing first). Full workspace nextest: 10337 pass, 0 regressions. NOT pushed yet (awaiting permission)."}}}
{"id":"bd-3zst4hwy","title":"Clean up clippy debt and gate clippy in CI","description":"CI never runs clippy; ~69 violations have accrued across 11 crates (quarto-hub 42, xtask 11, quarto-system-runtime 7, others) once the existing [workspace.lints.clippy] allow-policy is respected. Dominant lints: collapsible_if (32), map_unwrap_or (13), needless_borrows_for_generic_args (5) — mostly clippy --fix auto-fixable. A few hand-fixes / #[allow]: should_implement_trait, naive_bytecount, get_unwrap. Plan: fix all 69, then add a CI step + xtask leg running 'cargo clippy --workspace --all-targets -- -D warnings' so it stays clean (gate-and-grow, but small enough to finish in ~1-2 sessions). Architectural lints (result_large_err, large_enum_variant, too_many_arguments, type_complexity, ptr_arg) are already allowed in the workspace table and stay allowed. Discovered while adding q2 mcp --print-config (bd-9a8yu2gw).","status":"closed","priority":2,"issue_type":"chore","created_at":"2026-06-13T15:45:10.467531Z","created_by":"Carlos Scheidegger","updated_at":"2026-06-13T16:38:17.617590Z","closed_at":"2026-06-13T16:38:17.617590Z","close_reason":"Workspace clippy clean (cargo clippy --workspace --all-targets -- -D warnings exits 0); gate added to CI (test-suite.yml) + cargo xtask verify Step 1. ~525 violations fixed (auto + hand). 10036 tests pass. Branch beads/clippy-cleanup-gate, commits c9445494/25c1e187/d1cb1dec(+plan). Not pushed; hub-client test leg fails on pre-existing missing-WASM env, orthogonal.","dependencies":{"bd-9a8yu2gw:discovered-from":{"depends_on_id":"bd-9a8yu2gw","type":"discovered-from","created_at":"2026-06-13T15:45:10.467531Z","created_by":"Carlos Scheidegger"}},"comments":{"c-g0rfqzgg":{"id":"c-g0rfqzgg","author":"Carlos Scheidegger","created_at":"2026-06-13T16:09:44.958893Z","text":"SCOPE CORRECTION + progress checkpoint. The initial '69 violations' was a measurement artifact: clippy -D warnings aborts a crate on its first lint, masking the rest, so quarto-core (~240) was hidden until upstream crates were fixed. True scope is 250+ across the workspace, quarto-core-dominated. Progress: auto-fixed the bulk via cargo clippy --fix (now ~150-file diff, +/-); hand-fixed/allowed the judgment lints. Down to 28 remaining (mostly MaybeIncorrect single_match/filter_map_next + test-data approx_constant + deliberate from_str should_implement_trait). NOTE: had to use --broken-code to apply quarto-core fixes (normal cargo fix reverts a whole crate if any fix breaks compile); this exposed a clippy bug that expanded a matches!() macro into its raw template (compile_theme_css.rs) + cascaded an import removal — caught and hand-fixed; workspace cargo check --all-targets is green. NOT YET DONE: full nextest run to validate the auto-fixes, finish last 28, add the CI gate. Remaining list saved to claude-notes/2026-06-13-clippy-remaining.txt. Uncommitted on branch beads/clippy-cleanup-gate."}}}
+{"id":"bd-3zuggmsr","title":"q2 preview: active page outside _quarto.yml render: list shows \"Project render produced no output\"","description":"In project preview, navigating to (or launching on) a page that is NOT in the project's `render:` list (e.g. a scratch .qmd at the project root) shows the Render Error banner \"Project render produced no output for the active page\", even though the page is valid.\n\nROOT CAUSE (confirmed): RenderMode::ActivePage filters project.files, which discovery builds from the render: globs. A page outside those globs is absent from project.files, so pass_two produces zero outputs; the WASM entry point (wasm-quarto-hub-client/src/lib.rs:1633) then hits the catch-all because the active page is not among pass1_failures either. It is NOT sibling-capture-failure poisoning and NOT a poisoned engine host (fail_fast defaults false; proven with a clean no-broken-siblings native repro at the orchestrator level).\n\nQ2 has no single-file escape hatch for an in-project file: crates/quarto/src/commands/preview.rs resolve_project_and_initial_page forces project mode for any file under a _quarto.yml ancestor (single_file: None, pinned by test at preview.rs:575); --no-project is mutually exclusive with a positional path. So without a fix, a render:-excluded in-project page is unreachable in preview.\n\nQ1 (TypeScript quarto-cli) comparison, source-read only: it is mode-dependent. Direct 'quarto preview foo.qmd' renders a render:-excluded file (cmd.ts single-file render() fallback bypasses project.files.input). But navigating to a non-member inside a running project preview REFUSES via previewUnableToRenderResponse() (serve.ts:877) / 404 -- the direct analog of Q2's message. So Q2's current behavior matches Q1's navigation mode; the real defect is (a) the confusing message and (b) no way to preview the page at all.\n\nDESIGN DECISION (open -- no fix landed):\n- Option 1: orchestrator injects the ActivePage target into project.files so it renders on-demand in project mode. Prototyped and verified end-to-end (native RED->GREEN test + q2 preview + headless Chromium: sum_sines2.qmd rendered with 5 marimo islands). BUT the render:-excluded page renders with DEGRADED nav chrome: sidebar container present but EMPTY (no links, missing 'nav-sidebar docked' body classes) because the page is not a member of any website.sidebar.contents entry. Looks half-wired.\n- Option 2: route the render:-excluded active page through the single-doc renderer (standalone, no project chrome) -- closer to Q1's direct-invocation single-file semantics; sidesteps the empty-sidebar artifact; but does not give project theme/nav and is a larger CLI+WASM change.\n\nSTATUS: Option 1 prototype was REVERTED at Gordon's request pending the design decision above. Nothing committed. See conversation 2026-07-24.","status":"open","priority":1,"issue_type":"bug","created_at":"2026-07-24T16:57:57.818860Z","created_by":"Gordon Woodhull","updated_at":"2026-07-24T22:04:58.757367Z","labels":["bug","preview"],"comments":{"c-0b5awo7m":{"id":"c-0b5awo7m","author":"Gordon Woodhull","created_at":"2026-07-24T22:04:58.757367Z","text":"2026-07-24: Prototyped Option 1 (orchestrator injects ActivePage target into project.files). Verified end-to-end but found it renders render:-excluded pages with an empty sidebar rail (page is not a member of any configured sidebar). Reverted the prototype (both orchestrator.rs and the regression test) pending decision between Option 1 (project render, degraded nav) and Option 2 (single-file render for excluded pages, Q1-parity). No code committed."}}}
{"id":"bd-41dros0u","title":"node_tests.rs unused PathBuf import fails clippy on Windows (-D warnings)","description":"std::path::PathBuf is imported unconditionally at crates/quarto-mcp-launcher/tests/integration/node_tests.rs:11, but its only consumer is fake_node at line 22, which is #[cfg(unix)]-gated. On Windows this makes the import unused, failing cargo clippy --all-targets -D warnings. CI (Linux/Mac) is unaffected. Fix: move the use inside the #[cfg(unix)] block or gate it with #[cfg(unix)]. Discovered during bd-i9i5ad2t Phase 5 verify while fixing the sibling bd-nj9nnkn1 dead-code issue.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-07-02T11:54:46.698369Z","created_by":"cderv","updated_at":"2026-07-02T11:56:14.007894Z","closed_at":"2026-07-02T11:56:14.007894Z","close_reason":"Moved the PathBuf import into the #[cfg(unix)] fake_node fn (its only consumer); verified with cargo clippy -p quarto-mcp-launcher --all-targets -- -D warnings","labels":["windows"],"dependencies":{"bd-i9i5ad2t:discovered-from":{"depends_on_id":"bd-i9i5ad2t","type":"discovered-from","created_at":"2026-07-02T11:54:46.698369Z","created_by":"cderv"}}}
{"id":"bd-42owi5yb","title":"Selection-sync: pure helpers + parent applySelection (TDD scaffold)","description":"Pure, unit-testable core: sourceOffsetToLineCol(byteLineMap, offset)->{line,col}; parent-side applySelectionMessage(msg)->monaco.Selection with echo-guard (isSyncingRef window + editorHasFocusRef). No wiring yet. jsdom/vitest tests are the deliverable. Phase 0 of bd-6dib1198.","status":"open","priority":2,"issue_type":"task","created_at":"2026-06-25T21:27:50.121566Z","created_by":"shikokuchuo","updated_at":"2026-06-25T21:27:50.121566Z","dependencies":{"bd-6dib1198:parent-child":{"depends_on_id":"bd-6dib1198","type":"parent-child","created_at":"2026-06-25T21:27:50.121566Z","created_by":"shikokuchuo"}}}
{"id":"bd-45tfp790","title":"Localization design: title-block labels, date locales, locale-week tokens","description":"Deferred from the title-block parity epic (bd-gx9cic8z, decisions Q3/Q4 + P4 design doc): a lang-driven localization system covering title-block metadata labels (Q1's _language.yml), localized date named styles + month/day names, locale-week date tokens (w ww wo gggg), and local-timezone resolution for today/now keywords (currently UTC). Design doc pointers: claude-notes/plans/2026-07-17-date-formatting-design.md deviations 2/6; claude-notes/plans/2026-07-15-html-title-block-parity.md Q3.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-07-17T15:13:49.191932Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-17T15:13:49.191932Z","dependencies":{"bd-y71ga2l8:discovered-from":{"depends_on_id":"bd-y71ga2l8","type":"discovered-from","created_at":"2026-07-17T15:13:49.191932Z","created_by":"Carlos Scheidegger"}}}
@@ -278,6 +279,7 @@
{"id":"bd-9cyza5vy","title":"Single-file q2 preview: resolve the deck's full transitive sibling deps (includes + their assets), and re-resolve on edit","description":"Follow-up to bd-kpuweafo. ROOT CAUSE (verified E2E 2026-06-16): single-file q2 preview (no _quarto.yml) only syncs a narrow, statically-parsed set of siblings into the VFS, whereas q2 render reads the whole filesystem and project-mode preview walks the whole dir. So in single-file preview, anything the deck transitively needs that isn't pre-synced is broken.\n\nVerified matrix (single deck main.qmd with {{< include part.qmd >}} where part.qmd has ):\n- q2 render (single file): include resolves + image displays (naturalWidth 320). WORKS (reads disk directly).\n- q2 preview project (touch _quarto.yml): include resolves + image displays (blob). WORKS (dir walk syncs part.qmd + image).\n- q2 preview single-file: include renders as literal '?include' placeholder; 'Included Section' absent; image absent. BROKEN.\n\nSo the user's presumed inconsistency (includes work but their images don't) is actually: INCLUDES DON'T WORK EITHER in single-file preview - same root cause as the direct-image gap. bd-kpuweafo fixed only DIRECT image refs in the deck (parse deck -> collect Image URLs -> sync). It did NOT fix: (a) {{< include >}} of sibling .qmd (included file not in VFS -> shortcode fails); (b) images referenced INSIDE included files (transitive); (c) refs added mid-edit (resolution is one-shot at session start in build_hub_config).\n\nWHY HARD: bd-tnm3k single-file mode deliberately does NOT walk the directory (a bare 'q2 preview ~/Downloads/x.qmd' must not index all of ~/Downloads). So every sibling the deck needs must be DISCOVERED BY PARSING. quarto-hub has no qmd parser; extraction lives in quarto-preview (config::resolve_single_file_assets) and is injected via HubConfig. Includes also can't be resolved on-demand mid-render because the WASM include-expansion reads synchronously from the VFS - the file must be pre-synced.\n\nPROPOSED FIX: make single-file extraction TRANSITIVE and recursive: parse deck -> collect {{< include >}} targets (extract_include_path, already used by deps.rs) + Image URLs; for each included .qmd, recursively parse it -> collect its includes + images; sync included .qmd as text and all images as binary into the VFS. Bonus: re-run extraction on deck-change watch events so mid-edit-added refs sync without reload. Keep the under-deck-dir canonicalization guard (no ../ escape). This makes single-file preview match render/project for the deck's own dependency closure without a full dir walk. Plan: claude-notes/plans/2026-06-16-single-file-preview-referenced-assets.md (v1 limits section).","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-06-16T23:40:26.135173Z","created_by":"Carlos Scheidegger","updated_at":"2026-06-17T02:05:04.565000Z","closed_at":"2026-06-17T02:05:04.565000Z","close_reason":"Implemented + merged in PR #300 (feature/bd-9cyza5vy-single-file-preview-transitive-deps). Single-file q2 preview now resolves the deck's full transitive include + image closure via the renderer's own include-expansion run natively; included .qmd sync as invisible text deps; the watch set covers the closure. TDD + E2E verified; CI clean. Render-side nested-include retargeting bug remains open as bd-udrn0q47.","labels":["preview"],"dependencies":{"bd-kpuweafo:discovered-from":{"depends_on_id":"bd-kpuweafo","type":"discovered-from","created_at":"2026-06-16T23:40:26.135173Z","created_by":"Carlos Scheidegger"}},"comments":{"c-bni1dft5":{"id":"c-bni1dft5","author":"Carlos Scheidegger","created_at":"2026-06-16T23:51:38.773727Z","text":"Design-exploration plan written: claude-notes/plans/2026-06-16-single-file-preview-vfs-bootstrapping.md. Frames the bootstrapping paradox (populating the ephemeral VFS requires parsing files, but parsing requires the VFS to be populated), the verified behavior matrix, why single-file can't walk the dir (bd-tnm3k), the full set of dependency channels, and 5 design options (A transitive static extract / B lazy on-miss fetch / C render-driven fixpoint reusing pipeline deps / D bounded walk / E async file_read) with eval criteria + a tentative lean toward C. No implementation beyond bd-kpuweafo's direct-image sync; this is the explore-the-space step the user requested."},"c-podosqfn":{"id":"c-podosqfn","author":"Carlos Scheidegger","created_at":"2026-06-17T01:18:01.366247Z","text":"Implementation complete (pending review/commit). All 4 phases + spike done, TDD throughout.\n\nPhase 0 (spike): chosen entry point = run the renderer's OWN ParseDocumentStage + IncludeExpansionStage natively via pollster::block_on, so include path resolution matches q2 render exactly (incl. the bd-udrn0q47 nested-include behavior — preview inherits it automatically).\n\nPhase 1 (quarto-preview/config.rs): resolve_single_file_deps -> SingleFileDeps { qmd_files, binary_files }. Text deps = doc.recorded_includes (transitive, cycle-truncated); binary deps = collect_referenced_asset_urls over the EXPANDED AST, deck-dir-anchored (render parity / no retargeting). Under-root guard preserved. Deleted superseded resolve_single_file_assets. 6 new tests.\n\nPhase 2 (quarto-hub + preview): included .qmd ride a new text-only path — HubConfig.single_file_text_deps -> ProjectFiles.text_dep_files + with_text_deps (in text_files()/all_files()/counts, NOT qmd_files, so invisible/no nav). build_hub_config rewired to one resolve_single_file_deps call filling both fields. discovery.rs + context.rs tests.\n\nPhase 3 (quarto-hub/watch.rs + server.rs): WatchConfig.single_file_deps; FileWatcher subscribes deck + each closure dep (NonRecursive, best-effort) and accepts only allow-set members. server.rs builds the set from project_files().all_files() minus the deck. bd-tnm3k safety preserved (unrelated siblings still ignored) — pinned by test.\n\nPhase 4 (E2E): q2 preview main.qmd (no _quarto.yml), browser inspection of the render iframe: 'Included Section' renders (include expanded, no literal ?include), and the image INSIDE the include displays via blob URL (naturalWidth 1, the 1x1 PNG loaded). \n\ncargo xtask verify --skip-hub-build: All verification steps passed (clippy -D warnings + full workspace nextest). Plan: claude-notes/plans/2026-06-16-single-file-preview-transitive-deps.md"}}}
{"id":"bd-9eltv","title":"Profile q2 render on a large website (quarto-web)","description":"Use external-sources/quarto-web as a large-project fixture to characterize q2's render performance. Follow the native-proxy-first workflow in claude-notes/instructions/performance-profiling.md: flamegraph, env-gated counters, geometric scaling, then synthesis. Produces a written analysis and per-hotspot follow-up issues; not a fix.\n\nPlan: claude-notes/plans/2026-05-21-q2-render-website-profile.md","status":"closed","priority":2,"issue_type":"task","created_at":"2026-05-21T21:13:06.218065Z","created_by":"cscheid","updated_at":"2026-06-08T18:28:45.164112Z","closed_at":"2026-06-08T18:28:45.164112Z","close_reason":"Audit 2026-06-08: quarto-web render profiling deliverable produced (plan + research note + flamegraphs); blocker bd-wlza2 closed.","dependencies":{"bd-wlza2:blocks":{"depends_on_id":"bd-wlza2","type":"blocks","created_at":"2026-05-21T21:13:06.218065Z","created_by":"cscheid"}}}
{"id":"bd-9ez3ngt1","title":"reference-location ignored from front matter (as_str vs PandocInlines)","description":"reference-location set in document YAML front matter is silently ignored in ALL placements (top-level, nested under format.html) and for ALL values (margin/block/section) — rendering always falls back to the 'document' default.\n\nCONFIRMED root cause (runtime dump of get_reference_location in the real q2 render pipeline):\n\n reference-location: margin -> kind=PandocInlines, as_str()=None, as_plain_text()=Some(\"margin\")\n reference-location: !str margin -> kind=Scalar, as_str()=Some(\"margin\")\n\nIn document-metadata context, a bare YAML string value is parsed as markdown and stored as ConfigValueKind::PandocInlines (see quarto-pandoc-types/src/config_value.rs:189-195), NOT Scalar(String). ConfigValue::as_str() (config_value.rs:641-649) returns None for PandocInlines. So:\n\n FootnotesTransform::get_reference_location (crates/quarto-core/src/transforms/footnotes.rs:76-81)\n AppendixStructureTransform reference-location read (crates/quarto-core/src/transforms/appendix.rs:92)\n\nboth call .and_then(|v| v.as_str()) and get None -> ReferenceLocation::default() = Document.\n\nThe key is PRESENT; the accessor is wrong. The codebase already has the right convention: ConfigValue::as_plain_text() (config_value.rs:675-684) handles both Scalar(String) and PandocInlines, and is used deliberately elsewhere for exactly this (filter_resolve.rs:90,212 with the comment 'handle both Scalar(String) and PandocInlines forms'; document_profile.rs reads all meta strings via as_plain_text). The !str tag is a working-but-undocumented escape hatch today.\n\nFIX: replace .as_str() with .as_plain_text() in footnotes.rs:78 and appendix.rs:92 (audit appendix.rs for other reference-location reads too). Low risk, matches existing convention. TDD: add a failing e2e render test (front matter reference-location: margin -> margin-note class, no doc-endnotes section) before fixing.\n\nDistinct from bd-po3gn41h (named [^id] footnote refs, PR #264) and from bd-1kly (block/section numbering). Discovered while verifying bd-po3gn41h margin-mode parity.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-06-09T14:54:08.953791Z","created_by":"Carlos Scheidegger","updated_at":"2026-06-09T15:47:46.081783Z","closed_at":"2026-06-09T15:47:46.081783Z","close_reason":"Fixed and merged via PR #265 — reference-location honored from front matter (as_str -> as_plain_text).","external_ref":"https://github.com/quarto-dev/q2/pull/265","labels":["bug","footnotes"],"dependencies":{"bd-po3gn41h:discovered-from":{"depends_on_id":"bd-po3gn41h","type":"discovered-from","created_at":"2026-06-09T14:54:08.953791Z","created_by":"Carlos Scheidegger"}},"comments":{"c-1fy79tfx":{"id":"c-1fy79tfx","author":"Carlos Scheidegger","created_at":"2026-06-09T14:59:18.005535Z","text":"PR opened: https://github.com/quarto-dev/q2/pull/265 (branch bugfix/bd-9ez3ngt1-reference-location-front-matter, base main, commit a35df31a). Fix: as_str -> as_plain_text in get_reference_location of footnotes.rs + appendix.rs. e2e test + full verify green. Left in_progress pending merge."}}}
+{"id":"bd-9fwn1504","title":"quarto-ast-reconcile: proptest counterexample — reconciliation does not preserve structure (full AST)","description":"CI on PR #415 hit a failing random case in property_tests::reconciliation_preserves_structure_full_ast (crates/quarto-ast-reconcile/src/lib.rs:1242, 'Result should be structurally equal to after'). Reproduced locally with the CI seed on BOTH the PR branch and main — pre-existing latent bug, unrelated to that PR (which touches only quarto-hub/preview/hub-provider). Deterministic reproducer: create crates/quarto-ast-reconcile/proptest-regressions/lib.txt containing the line 'cc 2c379a4ae900cb3d235f771b204e0cb0e307ee6808562d588dc6ecd81088e38e' and run cargo nextest run -p quarto-ast-reconcile -E 'test(reconciliation_preserves_structure_full_ast)'. Note proptest hit max_shrink_iters=1024 while shrinking, so the stored case may be large; raising PROPTEST_MAX_SHRINK_ITERS should shrink it further for debugging. When fixing, commit the seed file as the regression pin (TDD: it fails first). Deliberately NOT committed on PR #415 — it would make that unrelated PR deterministically red.","status":"open","priority":1,"issue_type":"bug","created_at":"2026-07-24T21:23:43.948082Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-24T21:23:43.948082Z","dependencies":{"bd-eiku4ymo:discovered-from":{"depends_on_id":"bd-eiku4ymo","type":"discovered-from","created_at":"2026-07-24T21:23:43.948082Z","created_by":"Carlos Scheidegger"}}}
{"id":"bd-9fz5fweg","title":"Figures/floats/layout-panels CSS from _quarto-rules (blocked on class taxonomy)","description":"Once the float/layout DOM class taxonomy (bd-hcp8m3ve) lands, port _quarto-rules.scss: .quarto-layout-* family (L38–103), .quarto-figure* + alignment variants + figcaption.quarto-float-caption-* + div[id^=tbl-] positioning (L105–130, 140–151), figure.quarto-float-tbl captions (L243–253). Adapt selectors to whatever taxonomy bd-hcp8m3ve chooses. figure > p:empty/:first-child (L132–138) stay dropped — Q2 figures have no
children. Note _bootstrap-rules.scss already carries dead fragments (.quarto-layout-cell[data-ref-parent], responsive .quarto-layout-row) — reconcile, don't duplicate. Inventory rows 3/4a/4b/8.","status":"open","priority":3,"issue_type":"task","created_at":"2026-07-21T17:52:15.888586Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-21T17:52:15.888586Z","labels":["css","parity"],"dependencies":{"bd-4doe9lvt:parent-child":{"depends_on_id":"bd-4doe9lvt","type":"parent-child","created_at":"2026-07-21T17:52:15.888586Z","created_by":"Carlos Scheidegger"},"bd-hcp8m3ve:blocks":{"depends_on_id":"bd-hcp8m3ve","type":"blocks","created_at":"2026-07-21T17:52:15.888586Z","created_by":"Carlos Scheidegger"}}}
{"id":"bd-9h2g","title":"Cargo: upgrade scraper v0.22.0 → v0.26.0","description":"Major upgrade surfaced by cargo-upgrade survey 2026-05-04. Current 0.22.0 is range-pinned in workspace; latest is 0.26.0. Type: pre-1.0 minor (semver-breaking); four minor steps. Review changelog and bump deliberately. See claude-notes/plans/2026-05-04-cargo-upgrade-survey.md and bd-hb8h.","status":"closed","priority":3,"issue_type":"chore","created_at":"2026-05-04T18:15:55.070022Z","created_by":"cscheid","updated_at":"2026-05-04T20:30:45.306184Z","closed_at":"2026-05-04T20:30:45.306039Z","close_reason":"merged: 86464160","labels":["cargo","deps"],"dependencies":{"bd-hb8h:discovered-from":{"depends_on_id":"bd-hb8h","type":"discovered-from","created_at":"2026-05-04T18:16:05.270861Z","created_by":"cscheid"}}}
{"id":"bd-9hlja","title":"Coalesce per-page diagnostics by source location","description":"Add a coalescing pass in the render-summary printer: same-(code, source-location, title) diagnostics across pages collapse into one DiagnosticMessage emission with an 'Affected files: a, b, c (and N others)' tail. Default cap: 3 names + count.\n\nAPI lives in quarto-error-reporting (proposed coalesce.rs module). Call site is print_render_diagnostics in crates/quarto/src/commands/render.rs:704-735, which today walks pass2_failures and outputs[i].render_output.diagnostics independently and emits each verbatim.\n\nNon-coalescable shapes (SourceInfo::Concat, FilterProvenance) pass through as singletons in the first cut.\n\nThis is one of two children of the theme-diagnostic overhaul epic. Companion issue makes the theme error structured so this coalescer has something to coalesce.\n\nPlan: claude-notes/plans/2026-05-22-diagnostic-coalescing.md","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-05-22T13:43:11.958574Z","created_by":"cscheid","updated_at":"2026-05-22T15:05:25.328709Z","closed_at":"2026-05-22T15:05:25.328553Z","close_reason":"Implemented. Theme errors across all pages collapse into 1 ariadne block + 'Affected files: …' line. Verified end-to-end against quarto-web: 345 -> 1 emission.","labels":["diagnostics","website"],"dependencies":{"bd-l26u6:parent-child":{"depends_on_id":"bd-l26u6","type":"parent-child","created_at":"2026-05-22T13:43:11.958574Z","created_by":"cscheid"},"bd-pgczr:related":{"depends_on_id":"bd-pgczr","type":"related","created_at":"2026-05-22T13:43:11.958574Z","created_by":"cscheid"}}}
@@ -402,7 +404,7 @@
{"id":"bd-egcyeym9","title":"Extract quarto-yaml-validation into a standalone, non-Quarto-specific repository","description":"Architectural investigation + design for moving quarto-yaml-validation (and its outbound dependency closure: quarto-source-map, quarto-yaml, quarto-error-reporting) out of quarto-dev/q2 so non-Quarto developers can use the YAML-schema-validation infrastructure. Key design problem: error codes (Q-1-x) and the centralized catalog/docs URLs are Quarto-specific; the standalone library needs its own error-code provider while the q2-embedded build keeps existing codes. Requires extracting quarto-error-reporting into a reusable library with pluggable/remappable error-code catalogs. This session gathered current-state architecture; see linked research doc.","status":"open","priority":2,"issue_type":"epic","created_at":"2026-06-17T13:56:31.311381Z","created_by":"Carlos Scheidegger","updated_at":"2026-06-29T13:33:54.125548Z","labels":["architecture","research"],"comments":{"c-4iakr487":{"id":"c-4iakr487","author":"Carlos Scheidegger","created_at":"2026-06-26T17:16:26.928582Z","text":"Design refinement 2026-06-26 (cont.). User decisions:\n\nSEQUENCING (firm): extract-first, migrate-q2-LAST. Order: (1) foundation repo under posit-dev/ = quarto-source-map + error-reporting-core (split-out catalog-agnostic half), publish to crates.io, validate standalone; (2) yaml repo = quarto-yaml + quarto-yaml-validation as the FIRST CLIENT of the discipline, delete validate-yaml; (3) q2 migrates to published crates last. Rationale: invisible internal Posit consumers of quarto-yaml-validation need a real standalone repo; and the error-code discipline is a HOST contract that must be proven before its first client (error-reporting before yaml-validation). This inverts the earlier 'P0-P3 in q2 first' ordering.\n\nERROR-CODE DISCIPLINE crystallized into claude-notes/designs/cross-package-error-codes.md:\n- Fallback hierarchy: tier1 remapped to Q-code (best) > tier2 library origin code passthrough (acceptable) > tier3 codeless (FORBIDDEN). Library contract guarantees tier3 never happens (every emittable diagnostic carries a stable namespaced origin code), so the embedder remap is a pure upgrade tier2->tier1, optional/per-code. Refines I3: unmapped is acceptable, audit WARNS not fails.\n- Three contracts spelled out: library-author (own namespace, every error coded, stable, self-describe docs a la ESLint meta.docs.url), embedder/product (own remap+catalog, choose unmapped policy, never leak scheme upstream), shared-infra error-reporting-core (namespace-agnostic, provides CatalogProvider trait + remap hook + renderer = rustc LintStore analog).\n- n2 case (a non-q2 embedder) resolved: supplies its own remap+catalog over the same core; remap is per-embedder, not a q2 feature -> remap hook MUST live in error-reporting-core.\n- Clippy (clippy:: tool-lint namespace, own docs site, no E-codes) and ESLint (plugin/rule namespacing + meta.docs.url self-description) researched: both namespace + self-describe but EXPOSE the decomposition (they are platforms); q2 is a product that HIDES it, so q2 adds the remap they omit. TS = the compiler (flat numeric central, no cross-package design), good for presentation layer only.\n\nOPEN FORKS for user: repo granularity (1 foundation repo + 1 yaml repo [rec] vs single 4-crate workspace repo); keep dormant Q-1-* yaml catalog entries vs remove; project naming."},"c-5hksonc9":{"id":"c-5hksonc9","author":"Carlos Scheidegger","created_at":"2026-06-27T21:59:18.615124Z","text":"Phase 3 progress (2026-06-27): quarto-error-reporting standalone repo created + pushed. https://github.com/posit-dev/quarto-error-reporting (public). Standalone single-crate, version 0.1.0, depends on published quarto-source-map 0.1.0 + crates.io deps; json behind default-off feature (schemars optional). Builds default(json off, no schemars) + all-features; tests 51/61 + doctests + schema_drift; fmt+clippy clean both feature sets. CI workflow added (3 OSes, both feature sets), running. External-consumer smoke test passes (separate crate, default features, no schemars, EmptyCatalog + custom CatalogProvider both work). cargo publish --dry-run clean.\n\nSource fix for stable-clippy -D warnings: macros.rs items_after_test_module (q2 nightly clippy tolerated it) -> moved test mod below #[macro_export] macros + dropped redundant imports. q2 deletes its copy at cutover, so standalone is single source, no divergence. Dropped CONTRIBUTING-ERRORS.md (Quarto catalog policy) + rewrote README for the catalog-agnostic lib.\n\nNEXT: 3d publish to crates.io (USER step). Then 3e q2 cutover."},"c-6c2707we":{"id":"c-6c2707we","author":"Carlos Scheidegger","created_at":"2026-06-17T13:58:01.021204Z","text":"Current-state architecture written to claude-notes/research/2026-06-17-extract-quarto-yaml-validation.md\n\nKey findings:\n- Outbound closure to externalize = quarto-yaml-validation + 3 foundation crates (quarto-yaml, quarto-source-map [clean leaf], quarto-error-reporting).\n- Inbound: quarto-yaml-validation has only ONE in-repo consumer (validate-yaml bin) and is NOT wired into the render pipeline/config/WASM -> moves cleanly.\n- BUT the 3 foundation crates are heavily used inside q2 (source-map ~26 dependents, error-reporting ~19, quarto-yaml 8) -> they cannot move, must become shared/published deps consumed by both repos.\n- Hard design problem = error-code identity: yaml-validation hard-codes Q-1-x codes; quarto-error-reporting holds a centralized 145-entry error_catalog.json with quarto.org docs URLs, enforced by scripts/audit-error-codes.py. Standalone lib needs a pluggable/remappable catalog provider; q2-embedded build keeps Q-1-x. Likely split quarto-error-reporting into catalog-agnostic core + q2 catalog policy, or inject an ErrorCodeProvider trait.\n\nOpen questions (extraction strategy: own vs mirror vs publish-from-q2; core/catalog split; JSON wire types ownership; audit adaptation) listed at end of doc."},"c-6e55iwby":{"id":"c-6e55iwby","author":"Carlos Scheidegger","created_at":"2026-06-27T19:18:54.317557Z","text":"Step-1 plan revised (2026-06-27): TWO repos instead of one, and order flipped to leaf-first. Verified dependency is strictly one-directional: error-reporting-core -> quarto-source-map (SourceInfo is a field on DiagnosticMessage at diagnostic.rs:126; SourceContext threads through render/builder); source-map depends on nothing in error-reporting. This forces source-map to be published BEFORE error-reporting-core (crates.io rejects unpublished path deps), so the user's proposed 'error-reporting first' order is flipped. Plan restructured into: Phase 1 = extract quarto-source-map (trivial leaf; the warmup that proves repo-setup + crates.io publish + WASM cutover on the easy crate), Phase 2 = split error-reporting in place (independent, can overlap Phase 1), Phase 3 = extract error-reporting-core (needs source-map published + split done). q2 cutover is incremental (source-map first at 1d, core at 3e). Granularity decision now DECIDED=two-repos. Remaining open forks: core name, version start (0.1.0 rec), crates.io vs git dep, the two repo names."},"c-79nicwe8":{"id":"c-79nicwe8","author":"Carlos Scheidegger","created_at":"2026-06-27T20:26:52.855642Z","text":"PR #348 CI fully GREEN: all 5 checks pass — Run test suite (macos-latest)x2, (ubuntu-latest)x2, Hub-Client E2E. PR MERGEABLE / mergeStateStatus CLEAN, no review required. This confirms the quarto-source-map cutover in a clean CI env on both OSes incl. the WASM/hub leg. Phase 1 verified end-to-end; awaiting user decision to merge."},"c-91u3x7zk":{"id":"c-91u3x7zk","author":"Carlos Scheidegger","created_at":"2026-06-26T18:27:21.939037Z","text":"Step-1 plan written: claude-notes/plans/2026-06-26-extract-error-reporting-foundation.md — extract quarto-source-map + error-reporting-core (renamed, TBD) into a posit-dev/ repo, publish to crates.io, cut q2 over. Key measured finding: the catalog coupling inside error-reporting is ONE line (diagnostic.rs:290 docs_url -> catalog::get_docs_url) + only 2 external q2 callers (quarto-core project_resources.rs, theme_diagnostic.rs); direct dependents 14 (source-map) / 9 (error-reporting), not the transitive 26/19. Split: core = diagnostic+builder+macros+ErrorCodeInfo+CatalogProvider(OnceLock registry, std not once_cell, no schemars); q2 keeps error_catalog.json+QuartoCatalog provider+install() (quarto-error-catalog) + json.rs(wire)+coalesce.rs + a thin quarto-error-reporting façade so the 9 dependents compile unchanged. Three phases: A split-in-place (q2 green, TDD: installed catalog reproduces docs_url, empty catalog returns None), B new repo via git filter-repo + standalone CI with EmptyCatalog + publish, C q2 cutover (WASM is the risk surface, full xtask verify). Open forks at top: repo granularity (1 foundation repo rec), core name, version start (0.1.0 rec), crates.io vs git dep, repo name."},"c-98krqhge":{"id":"c-98krqhge","author":"Carlos Scheidegger","created_at":"2026-06-27T20:34:32.281773Z","text":"CI workflow for posit-dev/quarto-source-map added (.github/workflows/ci.yml, commit ee3780d, pushed to main). Stable Rust; test matrix ubuntu/macos/WINDOWS + fmt/clippy(-D warnings). First run GREEN on all 4 jobs (run 28300990636) — notably Windows builds clean (first time this crate has built on Windows; confirms it's genuinely cross-platform). Only the crates.io owner-add tidy-up remains (weekday)."},"c-d6qo0047":{"id":"c-d6qo0047","author":"Carlos Scheidegger","created_at":"2026-06-26T18:22:30.701335Z","text":"Discipline refinement 2026-06-26 (cont.3): APPEND-ONLY codes ('cool URLs for error codes', Berners-Lee 1998 applied to error ids). Folded into the design note + plan Q6.\n\nPrinciple: codes are unique and never deleted/repurposed. Lifecycle = Active -> Retired -> (never deleted). Legal transition Active->Retired (stop emitting, keep documented); FORBIDDEN: Active->Never (deletion) and code->different-meaning (repurposing, stronger than a major bump - simply off the table). A docs page accumulating no-longer-emitted codes is correct/expected (old versions, external references). This also protects the provenance breadcrumb: never-delete means a disclosed upstream code degrades to retired-but-documented, never 404 or silent-redefinition.\n\nFreeze binds at first PUBLIC exposure (emission or documentation), not first commit - pre-release dev churn is fine (semver pre-1.0 logic).\n\nEnforce intra-repo, encourage cross-repo (same asymmetry as provenance staleness): append-only is checkable within a repo (diff catalog vs git history/snapshot); not checkable across repos.\n\nCONCRETE CONSEQUENCE (the one place this bites existing code): q2's scripts/audit-error-codes.py is currently BIDIRECTIONAL. Must become: keep forward (every emitted code is documented), DROP reverse (every documented code is emitted - contradicts retirement), ADD append-only check. A retired/dormant code = legitimate catalog-only entry. ErrorCodeInfo.since_version already covers 'introduced'; optional retired_in/last_emitted for the window. This also largely settles the dormant-Q-1-* fork toward KEEP: any published yaml docs page is under the covenant."},"c-dhnsz7ix":{"id":"c-dhnsz7ix","author":"Carlos Scheidegger","created_at":"2026-06-26T16:58:23.019456Z","text":"Design session 2026-06-26. Two artifacts written:\n\n1. claude-notes/plans/2026-06-26-extract-quarto-yaml-validation-design.md — resolves the 7 open questions given the user's 'new repo owns foundation crates' lean. Key reframe: quarto-error-reporting is q2's whole diagnostics substrate (~19 dependents + shared JSON wire format), NOT an interchangeable foundation crate. Decision: split it into error-reporting-core (externalize, catalog-agnostic, CatalogProvider trait, no schemars, no quarto.org URLs) + quarto-error-catalog (stays in q2: Q-* catalog, quarto.org URLs, audit) + a thin quarto-error-reporting façade so the 19 call sites keep compiling. JSON wire types stay q2-side. Phased P0..P5 with P0-P3 landing entirely in q2 (deliver the seam) and P4-P5 the cross-repo commitment (separate go/no-go).\n\n2. claude-notes/designs/cross-package-error-codes.md — the general philosophy the user asked for. Two-identity model: ORIGIN codes (namespaced, package-owned, e.g. yaml-schema/type-mismatch — Clippy/ESLint precedent) + PRESENTATION codes (flat, product-owned Q-- — TS-compiler precedent). Product owns the remap. Invariant I1: subsystem != package, so q2 users never see the package decomposition. Corrected: 'TypeScript' = the language/compiler (flat central catalog, no cross-package design), not TS Quarto.\n\nUser clarifications folded in: validate-yaml to be DELETED (demo only; the ONLY in-repo consumer) -> quarto-yaml-validation will have ZERO q2 consumers, so the yaml remap is forward-looking/dormant until q2 wires the validator into config. Open forks for user: full repo move now vs stop after in-q2 split; keep dormant Q-1-* yaml catalog entries vs remove; project naming."},"c-erih8i4v":{"id":"c-erih8i4v","author":"Carlos Scheidegger","created_at":"2026-06-27T19:56:58.174762Z","text":"Phase 1 EXECUTED (2026-06-27): quarto-source-map extracted + PUBLISHED.\n\n- New repo: https://github.com/posit-dev/quarto-source-map (public). Standalone single-crate, version 0.1.0, edition 2024, builds on stable rustc 1.95 (no nightly). 104 unit + 4 doctests pass; cargo publish --dry-run clean.\n- PUBLISHED quarto-source-map 0.1.0 to crates.io (Carlos personal account; posit-dev owner-add deferred to a weekday). End-to-end crates.io pipeline exercised successfully (the point of doing the leaf first).\n- q2 cutover on branch braid/bd-egcyeym9-source-map-extraction: all 14 dependents consolidated onto { workspace = true }; root [workspace.dependencies.quarto-source-map] flipped path -> version=0.1.0; in-tree crates/quarto-source-map deleted. cargo build --workspace green; cargo nextest run --workspace = 10238 passed; Cargo.lock resolves from registry+crates.io with checksum. Full cargo xtask verify (WASM/hub leg) running now; cutover closes when green. NOT yet committed (awaiting verify + user go-ahead per GIT PUSH POLICY).\n- Gap to close: no GitHub Actions CI workflow in the new repo yet (tests run locally only).\n\nAll 4 crate names (source-map, error-reporting, yaml, yaml-validation) confirmed available on crates.io."},"c-gz6zngk0":{"id":"c-gz6zngk0","author":"Carlos Scheidegger","created_at":"2026-06-27T21:39:26.289270Z","text":"PR #349 (Phase 2) CI fully GREEN: all 5 checks pass (test suite macos x2 + ubuntu x2, Hub-Client E2E). MERGEABLE/CLEAN. Phase 2 verified in clean CI on both OSes incl. WASM. Awaiting user merge. Next: Phase 3 (extract quarto-error-reporting to posit-dev/ + crates.io), then the yaml stack."},"c-hr2nhh54":{"id":"c-hr2nhh54","author":"Carlos Scheidegger","created_at":"2026-06-29T13:33:54.125548Z","text":"Handoff note for the YAML stack written: claude-notes/plans/2026-06-29-yaml-stack-extraction-handoff.md. Self-contained plan for an agent to extract quarto-yaml + quarto-yaml-validation.\n\nSTRUCTURE DECISION (user, 2026-06-29): single repo posit-dev/quarto-yaml = a Rust WORKSPACE with two crates (NOT one-repo-per-crate like the foundation crates). They're tightly coupled (validation deps parser) + both Quarto-dialect-specific. Both still publish to crates.io independently, leaf-first (quarto-yaml then quarto-yaml-validation). Updated foundation plan decision #1 + the yaml design doc banner to point at the handoff.\n\nKey facts captured in the note: quarto-yaml is a clean leaf (only quarto dep = published source-map; NO error-reporting); 4 q2 consumers (pampa/config/core/lsp-core). quarto-yaml-validation deps quarto-yaml + published source-map + error-reporting; ONLY consumer is validate-yaml (delete it) -> ZERO q2 consumers after, so q2 deletes BOTH crates and doesn't depend on yaml-validation (it's published purely for external Posit consumers). WASM gotcha documented (pampa/core use workspace=true -> resolve to q2 root even in the wasm build; wasm likely needs no direct quarto-yaml dep; verify via full xtask verify). Error_code() Q-1-x -> origin codes yaml-schema/* per the discipline, flagged as a USER DECISION (breaking for existing Q-1-x consumers: A=origin from 0.1.0 [rec] vs B=defer to 0.2.0). All Phase 1/3 gotchas listed (CRLF/.gitattributes, stable-clippy, |tail masking, user-gated publish)."},"c-jgy534a7":{"id":"c-jgy534a7","author":"Carlos Scheidegger","created_at":"2026-06-27T20:07:14.077825Z","text":"Phase 1 committed + PR opened. Two commits on feature/bd-egcyeym9-source-map-extraction: (A) docs(design) — the discipline doc + 2 extraction plans; (B) build — the quarto-source-map cutover. PR #348: https://github.com/quarto-dev/q2/pull/348 (base main). Local full cargo xtask verify green (14 steps incl WASM). CI running (test suite macos+ubuntu, Hub-Client E2E). quarto-source-map 0.1.0 live at https://github.com/posit-dev/quarto-source-map + crates.io. Phase 1 done pending CI; tidy-ups deferred: new-repo CI workflow, crates.io owner-add."},"c-lrj5ylfo":{"id":"c-lrj5ylfo","author":"Carlos Scheidegger","created_at":"2026-06-27T20:04:07.040930Z","text":"Phase 1d cutover GREEN. Full cargo xtask verify passed all 14 steps incl. the WASM build (wasm-quarto-hub-client) + hub-client tests; cargo nextest run --workspace = 10238 passed. Both root Cargo.lock and wasm-quarto-hub-client/Cargo.lock resolve quarto-source-map 0.1.0 from registry+crates.io with matching checksum.\n\nGotcha hit + fixed: blanket crates/*/Cargo.toml path->workspace rewrite also touched wasm-quarto-hub-client, which is EXCLUDED from the main workspace and is its own standalone workspace (refs every q2 crate by path) -> 'workspace.dependencies not defined' at the wasm32 build. Fix: that crate gets a DIRECT version dep (quarto-source-map = \"0.1.0\"), only the 13 main-workspace members use { workspace = true }. Also: first verify's exit-0 was false (piped through tail, masking cargo's failure) — re-ran without tail.\n\nChange set on branch braid/bd-egcyeym9-source-map-extraction (UNCOMMITTED, awaiting go-ahead): root Cargo.toml dep path->version; 13 members ->workspace=true; wasm crate ->\"0.1.0\"; crates/quarto-source-map/ deleted; 2 Cargo.lock updated. Phase 1 functionally complete; remaining tidy-ups: new repo CI workflow + crates.io owner-add (both deferred)."},"c-ncwi1bbr":{"id":"c-ncwi1bbr","author":"Carlos Scheidegger","created_at":"2026-06-27T21:25:14.981381Z","text":"Phase 2 committed + PR opened. Two commits on feature/bd-egcyeym9-error-reporting-split: (A) refactor — catalog-agnostic + quarto-error-catalog extraction; (B) build — json feature gate. Split cleanly via revert-reapply (the two changes were interleaved in 5 shared files). PR #349: https://github.com/quarto-dev/q2/pull/349 (base main). Local full cargo xtask verify green (14 steps incl WASM); nextest 10240. CI running."},"c-pt5dfdvq":{"id":"c-pt5dfdvq","author":"Carlos Scheidegger","created_at":"2026-06-27T22:19:18.503399Z","text":"Phase 3 COMPLETE (2026-06-27): quarto-error-reporting 0.1.0 PUBLISHED to crates.io; q2 cut over. PR #350: https://github.com/quarto-dev/q2/pull/350. Cutover = pure dep-source flip (path->version 0.1.0) + delete in-tree crate; json wiring from Phase 2 carried over untouched; quarto-error-catalog + 4 json consumers stay in q2. Local: nextest --workspace 10177; full cargo xtask verify green (14 steps incl WASM). CLAUDE.md updated (both foundation crates now in an 'Externalized foundation crates' section). CI running.\n\nBOTH foundation crates now external & published: posit-dev/quarto-source-map 0.1.0 + posit-dev/quarto-error-reporting 0.1.0. NEXT: the YAML stack (quarto-yaml + quarto-yaml-validation) per the sibling plan — the original goal of the epic."},"c-q71dv5xw":{"id":"c-q71dv5xw","author":"Carlos Scheidegger","created_at":"2026-06-27T21:12:36.409886Z","text":"Phase 2 COMPLETE (2026-06-27): quarto-error-reporting is now catalog-agnostic; full cargo xtask verify GREEN (all 14 steps incl WASM + hub tests); workspace nextest 10240 passed.\n\nWhat changed:\n- quarto-error-reporting: catalog.rs gutted of data -> CatalogProvider trait + EmptyCatalog + std OnceLock registry + install_catalog; get_docs_url/get_error_info/get_subsystem keep signatures, delegate to installed provider. ERROR_CATALOG static + include_str removed. once_cell dropped (uses std OnceLock).\n- NEW crate quarto-error-catalog: error_catalog.json (git-moved) + QuartoCatalog provider + install(); 10 data-presence tests ported + 3 install/delegation integration tests. Example moved here.\n- json.rs behind default-off 'json' feature (schemars optional); 4 consumers (quarto, quarto-core, quarto-preview, wasm) opt in. cargo tree confirms schemars absent by default.\n- install() wired into q2 binary main. WASM deliberately does NOT install (catalog would include_str! 46KB into bundle, breaking hub-client 35MiB PWA precache limit; WASM never surfaces docs URLs -> EmptyCatalog is the correct per-embedder choice).\n- 2 quarto-core data-presence tests query quarto_error_catalog::ERROR_CATALOG directly (dev-dep). audit-error-codes.py + ~25 path refs updated to crates/quarto-error-catalog/.\n\nKey insight: catalog is fully decoupled from production rendering (docs_url has 0 consumers; 0 snapshots contain a URL) -> carve-out is behaviour-neutral, no snapshot churn.\n\nUncommitted on branch braid/bd-egcyeym9-error-reporting-split. Phase 3 (extract the crate to posit-dev/) is next."},"c-syi58b2z":{"id":"c-syi58b2z","author":"Carlos Scheidegger","created_at":"2026-06-27T22:32:57.802870Z","text":"PR #350 (Phase 3 cutover) CI fully GREEN: all 5 checks pass (test suite macos x2 + ubuntu x2, Hub-Client E2E). MERGEABLE/CLEAN. Awaiting user merge. Both foundation crates now external+published; YAML stack is the remaining work."},"c-szmu8xji":{"id":"c-szmu8xji","author":"Carlos Scheidegger","created_at":"2026-06-26T18:18:31.559624Z","text":"Discipline refinement 2026-06-26 (cont.2). Folded into claude-notes/designs/cross-package-error-codes.md:\n\nROLES ARE PER-NODE: 'library' vs 'product' collapses into one role applied at each hop. Every node = a DEFINER (mints terminal codes) + optional REMAPPER (relabels dependency codes). Q2 is just the TERMINAL remapper (its codes are user-facing). Chains bottom out at terminal codes; the library contract (every error has a stable code) guarantees every chain terminates.\n\nTERMINAL vs REMAPPED provenance lane added (developer-facing, not user-facing; structured/JSON only). Three rules: (1) provenance is INERT DATA {code, source_url?}, never a typed dependency on the upstream error enum (would rebuild the coupling we're removing); (2) disclose the IMMEDIATE upstream + self-declared terminal flag, NOT a resolved-ultimate pointer (stale-proof under no-traversal; immediate==terminal in the common 1-hop case); (3) NO automated cross-repo traversal and no resolved-ultimate; disclosure is best-effort breadcrumb, optional version/commit pin, no cross-repo CI possible. Design-for-1-hop: permit chaining, build zero resolver.\n\nerror-reporting-core naming flagged: general infra under posit-dev/, likely drops quarto- prefix (candidates: diagnostic-core/reportkit/etc) — deferred, bikeshed.\n\nRemaining nit: terminal as explicit bool flag vs implied-by-absence-of-provenance (lean: implied-by-absence)."},"c-uc2a0w79":{"id":"c-uc2a0w79","author":"Carlos Scheidegger","created_at":"2026-06-27T19:35:27.601940Z","text":"Naming decided (2026-06-27): externalized crates KEEP their current names — quarto-source-map, quarto-error-reporting (and quarto-yaml later); rename only 'if it comes to it'. Non-obvious consequence folded into the foundation plan: keeping the name means the externalized crate IS quarto-error-reporting (no error-reporting-core rename), which KILLS the planned q2-side façade (name collision). Simpler result: the 9 dependents depend on the external quarto-error-reporting directly (imports unchanged); the ONLY q2-side carve-out is quarto-error-catalog (the Q-* catalog DATA + ERROR_CATALOG static + QuartoCatalog provider + install()). json.rs + coalesce.rs STAY in the external crate, json behind a default-off 'json' feature (q2 enables it) -> zero import churn for json/coalesce consumers (wasm/hub/publish/trace/parse-errors/mcp/preview); reverses earlier Q4/Q5 'move json q2-side' but the feature-gate keeps non-Quarto builds schemars-free. Updated docs: foundation plan (full), design note (renamed error-reporting-core -> quarto-error-reporting throughout), yaml plan (superseding banner; its internal façade/core/json-relocation refs are now historical). Remaining open forks: version start (0.1.0 rec), crates.io vs git dep, the two posit-dev/ repo slugs."},"c-wwrizzhk":{"id":"c-wwrizzhk","author":"Carlos Scheidegger","created_at":"2026-06-27T22:02:56.740082Z","text":"quarto-error-reporting repo CI fully GREEN on all 3 OSes after a CRLF fix. Windows initially failed schema_drift (committed schemas/*.json checked out CRLF vs serde_json LF output); fixed with .gitattributes '* text=auto eol=lf' + renormalize. q2's CI (linux+macos only) never caught this — the standalone Windows matrix did. Same latent bug exists in q2's schema_drift copy but is moot (q2 deletes its copy at 3e cutover). Repo ready to publish. NEXT: 3d user publishes quarto-error-reporting 0.1.0 to crates.io."}}}
{"id":"bd-ehyyfpjj","title":"revealjs code blocks: highlight spans emitted but highlight CSS missing (uncolored code)","description":"In format: revealjs, code cells get the tree-sitter hl-* span annotations (CodeHighlightStage runs) but render UNCOLORED because the compiled reveal theme CSS contains no .hl-* rules. HTML/q2-preview bundle highlight.scss into styles.css via the highlight_layer in every compile_theme_css variant; the reveal path (quarto-sass assemble_reveal_scss / compile_reveal_theme_css) omits highlight_layer entirely. Reproduces in all three paths (q2 render, q2 preview, hub-client) since it is shared Rust.\n\nhighlight.scss (resources/scss/html/templates/highlight.scss) is self-contained (/*-- scss:rules --*/, literal colors, no theme-variable deps), so the fix is to include the highlight layer in assemble_reveal_scss/compile_reveal_theme_css. Watch CSS specificity: reveal's own .reveal pre code rules may override bare .hl-* selectors; verify computed colors and scope under .reveal if needed.\n\nFix location: crates/quarto-sass/src/bundle.rs assemble_reveal_scss (~382) + crates/quarto-sass/src/compile.rs compile_reveal_theme_css (~374). TDD: assert reveal compiled CSS contains .hl-keyword, mirroring the existing HTML test (~compile.rs:620).","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-06-22T16:11:41.351966Z","created_by":"Carlos Scheidegger","updated_at":"2026-06-22T16:36:50.812802Z","closed_at":"2026-06-22T16:36:50.812802Z","close_reason":"Fixed in 984874f8 (pushed to main): assemble_reveal_scss now bundles the highlight layer so revealjs code blocks get .hl-* colours. Verified end-to-end on q2 render (native) and hub-client/q2 preview (WASM) — identical colours to HTML. TDD test added; full workspace nextest + cargo xtask verify green.","labels":["bug"],"comments":{"c-n344uejb":{"id":"c-n344uejb","author":"Carlos Scheidegger","created_at":"2026-06-22T16:33:01.635122Z","text":"cargo xtask verify (full, incl. WASM rebuild + hub-client build:all + test:ci) GREEN. Verified hub-client preview end-to-end: code blocks now highlighted with identical colours to q2 render (hl-function blue rgb(38,139,210), hl-keyword green rgb(133,153,0), hl-number magenta rgb(211,54,130)). Fix confirmed across all three paths. Branch braid/bd-ehyyfpjj-revealjs-code-highlight-css (commit 561aaed6) ready for review/PR."},"c-wafa88b4":{"id":"c-wafa88b4","author":"Carlos Scheidegger","created_at":"2026-06-22T16:27:28.035816Z","text":"Root cause: assemble_reveal_scss (crates/quarto-sass/src/bundle.rs) omitted the highlight layer that every HTML compile bundles, so the compiled reveal theme had no .hl-* colour rules — the hl-* spans rendered uncoloured. Fix (commit 561aaed6): include load_highlight_layer() in assemble_reveal_scss, before user theme layers. Shared by native + WASM reveal compiles, so it fixes q2 render, q2 preview, and hub-client. Verified q2 render end-to-end (computed colours blue/green/magenta). Full workspace nextest green (10309). cargo xtask verify (WASM + hub-client) running to confirm the WASM-backed paths; will verify hub-client preview after the WASM rebuild."}}}
{"id":"bd-eias3e39","title":"Audit _quarto-rules.scss: categorized selector inventory (port-now / blocked-on-emitter / dropped)","description":"Produce the categorized inventory that scopes the rest of epic bd-4doe9lvt. For each of the ~80 top-level selectors in TS Quarto's _quarto-rules.scss, determine:\n\n1. Is the rule ALREADY present in Q2's SCSS (_bootstrap-rules.scss / title-block.scss / copy-code.scss / highlight.scss / embed-example.scss / page-footer)? \n2. Does Q2's HTML writer EMIT the DOM the selector targets? (grep the writers/transforms; render a fixture exercising the feature and inspect.)\n3. Categorize: PORT-NOW (DOM emitted, rule missing) / BLOCKED-ON-EMITTER (rule would be dead CSS until Q2 emits the DOM — link the emitter feature) / ALREADY-PRESENT / INTENTIONALLY-DROPPED (with reason).\n\nDeliverable: a table in claude-notes/research/2026-07-DD-quarto-rules-scss-inventory.md, and follow-up child strands under bd-4doe9lvt: themed PORT-NOW strands (candidate groupings from the initial scan: figures/floats [quarto-figure, quarto-float-caption internals], code-overflow [code-overflow-wrap/scroll], footnotes [footnote-back, tippy footnote], layout panels [quarto-layout-panel], cover-image, unresolved-ref, details/summary, task-list tweaks), and BLOCKED strands with 'blocks' deps on their emitter features.\n\nInitial coverage scan (from bd-btjkyylx session, NOT authoritative — verify each): PRESENT-ish = quarto-layout-cell, quarto-float-caption, title-block-header(now complete), code-copy-outer-scaffold, task-list, tippy, panel-input, quarto-embedded-source-code. MISSING-ish = quarto-layout-panel, quarto-figure, code-overflow-wrap, footnote-back, quarto-cover-image, quarto-unresolved-ref, widget-subarea, knitsql-table, abstract-title, quarto-float-tbl. The MISSING set mixes port-now (Q2 emits the DOM) and blocked (Q2 doesn't yet) — that split is the whole point of the audit.\n\nParent epic: bd-4doe9lvt. Plan: claude-notes/plans/2026-07-21-quarto-rules-scss-parity-epic.md","status":"closed","priority":2,"issue_type":"task","created_at":"2026-07-21T16:00:22.550418Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-21T17:53:14.041657Z","closed_at":"2026-07-21T17:53:14.041657Z","close_reason":"Audit complete: inventory at claude-notes/research/2026-07-21-quarto-rules-scss-inventory.md (commit 129dfedc); 9 follow-up strands filed under/around epic bd-4doe9lvt + task-list bug bd-obkvhlam","labels":["css","parity"],"dependencies":{"bd-4doe9lvt:parent-child":{"depends_on_id":"bd-4doe9lvt","type":"parent-child","created_at":"2026-07-21T16:00:50.909888Z","created_by":"Carlos Scheidegger"}},"comments":{"c-2pb8wvm2":{"id":"c-2pb8wvm2","author":"Carlos Scheidegger","created_at":"2026-07-21T17:25:47.622144Z","text":"Epic plan updated with code pointers for the audit handoff (bundle.rs / resources.rs / writers / compile-test / baseline). Recommend starting this in a fresh session or a dedicated worktree (/investigate-beads bd-eias3e39) — the useful context is fully captured in the plan + this strand."},"c-oc19f712":{"id":"c-oc19f712","author":"Carlos Scheidegger","created_at":"2026-07-21T17:30:59.472422Z","text":"Investigation done (plan skeleton at claude-notes/plans/2026-07-21-quarto-rules-scss-audit.md, commit 04882745 on main). Verdict: ready to design. Note: the 'epic plan updated with code pointers' comment refers to pointers that were never committed — they're reconstructed in the skeleton from the bd-btjkyylx plan. Extracted 144 depth-0 selectors (vs ~80 family-grouped) to plans/quarto-rules-scss-audit-investigation/top-level-selectors.tsv."}}}
-{"id":"bd-eiku4ymo","title":"Capture docs: uncompressed audit/GC metadata envelope (createdAt, sourcePath, engines)","description":"Engine-capture binary docs (mimeType application/x-engine-capture+gzip) are orphaned in samod storage on every re-execution: perform_re_execute creates a new binary doc and repoints the index sidecar's CaptureRef, and nothing ever deletes the old doc. On quarto-hub deployments these accumulate forever, and bd-qbhp2cvv (embedding engine supporting-file bytes in captures) will make each one substantially bigger. Proposal: add a small UNCOMPRESSED top-level automerge 'meta' map beside content/mimeType/hash (via a create_binary_document_with_meta variant in quarto-hub/src/resource.rs) with kind:'engine-capture' + schema version, createdAt, sourcePath (project-relative qmd path), and engines (e.g. ['knitr']). Placement matters: mimeType already classifies capture docs without gunzipping, but anything inside the gzipped content payload is invisible to sync-server audits. With provenance in place, a storage-hygiene job gets a safe policy: capture-MIME docs not referenced by any index sidecar AND older than N days -> collect, with per-project accounting. Touches all three capture writers: quarto-preview/src/capture_driver.rs, quarto-preview/src/re_execute.rs (write_capture_doc), quarto-hub-provider/src/execute.rs. Context: discussion recorded in claude-notes/plans/2026-07-23-preview-engine-supporting-files.md (bd-qbhp2cvv).","status":"open","priority":2,"issue_type":"feature","created_at":"2026-07-23T19:03:07.254071Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-23T19:03:07.254071Z","dependencies":{"bd-qbhp2cvv:related":{"depends_on_id":"bd-qbhp2cvv","type":"related","created_at":"2026-07-23T19:03:07.254071Z","created_by":"Carlos Scheidegger"}}}
+{"id":"bd-eiku4ymo","title":"Capture docs: uncompressed audit/GC metadata envelope (createdAt, sourcePath, engines)","description":"Engine-capture binary docs (mimeType application/x-engine-capture+gzip) are orphaned in samod storage on every re-execution: perform_re_execute creates a new binary doc and repoints the index sidecar's CaptureRef, and nothing ever deletes the old doc. On quarto-hub deployments these accumulate forever, and bd-qbhp2cvv (embedding engine supporting-file bytes in captures) will make each one substantially bigger. Proposal: add a small UNCOMPRESSED top-level automerge 'meta' map beside content/mimeType/hash (via a create_binary_document_with_meta variant in quarto-hub/src/resource.rs) with kind:'engine-capture' + schema version, createdAt, sourcePath (project-relative qmd path), and engines (e.g. ['knitr']). Placement matters: mimeType already classifies capture docs without gunzipping, but anything inside the gzipped content payload is invisible to sync-server audits. With provenance in place, a storage-hygiene job gets a safe policy: capture-MIME docs not referenced by any index sidecar AND older than N days -> collect, with per-project accounting. Touches all three capture writers: quarto-preview/src/capture_driver.rs, quarto-preview/src/re_execute.rs (write_capture_doc), quarto-hub-provider/src/execute.rs. Context: discussion recorded in claude-notes/plans/2026-07-23-preview-engine-supporting-files.md (bd-qbhp2cvv).","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-07-23T19:03:07.254071Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-24T16:32:37.060297Z","dependencies":{"bd-qbhp2cvv:related":{"depends_on_id":"bd-qbhp2cvv","type":"related","created_at":"2026-07-23T19:03:07.254071Z","created_by":"Carlos Scheidegger"}},"comments":{"c-4ipghuue":{"id":"c-4ipghuue","author":"Carlos Scheidegger","created_at":"2026-07-24T14:12:34.813445Z","text":"Scope expanded on review (Carlos, 2026-07-24): in addition to the metadata envelope, design+implement minimal sync-server maintainer tools (hub admin scan/collect/restore/purge) — scan inventories a samod storage location and emits a versioned manifest of safely-removable orphaned capture docs; collect quarantines (never unlinks) from a manifest only, with re-verification; purge is the only unlink, behind a retention window. Full design for review: claude-notes/plans/2026-07-24-capture-meta-and-hub-admin-tools.md (branch braid/bd-eiku4ymo-capture-docs-uncompressed-auditgc)."},"c-58h7udo0":{"id":"c-58h7udo0","author":"Carlos Scheidegger","created_at":"2026-07-24T16:32:37.060297Z","text":"PR opened: https://github.com/quarto-dev/q2/pull/415 (feature/bd-eiku4ymo-hub-admin-tools, 6 commits). All phases done: meta envelope, classifier, scan+manifest, collect/restore/purge with quarantine + AdminLock, hub admin CLI, binary E2E (recorded in plan), runbook. verify --skip-hub-build green. Close on merge."},"c-70tg9rr7":{"id":"c-70tg9rr7","author":"Carlos Scheidegger","created_at":"2026-07-24T14:43:37.091068Z","text":"Phases A-B3 committed (38af41d3 meta envelope; B1 classifier; e1ed2c3b scan+manifest incl. the load_range splay discovery; 4ffc1f8f collect/restore/purge with AdminLock). B4 in progress: hub admin CLI wired; binary E2E first run caught a stale q2 binary whose captures were genuinely unstamped — the tools correctly protected them (live validation of the legacy-capture gate); rerunning with a fresh build. Runbook drafted at claude-notes/instructions/hub-storage-hygiene.md."}}}
{"id":"bd-eips","title":"L9 follow-up: format.metadata.description as channel description fallback","description":"Q1 cascades feed.description → format.metadata.description → website.description for the channel description. v1 cascades feed.description → website.description (skips the per-format layer). The simpler cascade matches Q2's configuration model. File this if a user needs the third level. Site: feed/binding.rs::website_description helper.","status":"open","priority":4,"issue_type":"feature","created_at":"2026-05-08T17:33:26.773014Z","created_by":"cscheid","updated_at":"2026-05-08T17:33:26.773014Z","dependencies":{"bd-o90m:discovered-from":{"depends_on_id":"bd-o90m","type":"discovered-from","created_at":"2026-05-08T17:33:26.773014Z","created_by":"cscheid"}}}
{"id":"bd-eity","title":"Generic file uploader dialog for hub-client","description":"Hub-client currently only supports dropping images onto the editor or sidebar. Users need to be able to upload arbitrary binary files (PDFs, CSVs, fonts, tree-sitter grammar .wasm files, etc.) into their Automerge-backed projects. The ingestion pipeline is already generic (processFileForUpload → createBinaryFile → VFS); what's missing is the UI affordance — a non-image-specific uploader dialog + triggers.\n\nPlan: claude-notes/plans/2026-04-21-generic-file-uploader.md\n\nBlocks: real-browser end-to-end verification for syntax-highlighting Phase 4 (loading a user tree-sitter grammar from _quarto/grammars/). See claude-notes/plans/2026-04-21-syntax-highlighting-phase-4.md step 4.6.\n\nScope (see plan for full details):\n- Generalize NewFileDialog's file-input accept filter (currently image/*,.pdf,.svg)\n- Route non-image editor drops to the upload dialog instead of discarding them\n- Add a '+' / 'Add files' entry point in the FileSidebar\n- Destination-path picker\n- Preserve existing image-drop markdown-insertion UX\n\nMostly UI-layer work; the binary ingestion pipeline stays unchanged.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-04-21T13:38:41.407334Z","created_by":"cscheid","updated_at":"2026-04-21T14:55:28.078909Z","closed_at":"2026-04-21T14:55:28.078161Z","close_reason":"Implemented in b0177b8d (plan 2026-04-21-generic-file-uploader)","dependencies":{"bd-n7x2:related":{"depends_on_id":"bd-n7x2","type":"related","created_at":"2026-04-21T13:38:41.407334Z","created_by":"cscheid"}}}
{"id":"bd-eizgnxlx","title":"Migrate crossref codeblock-shorthand to the shared cell_options facility","description":"quarto-core/src/crossref/codeblock_shorthand.rs parses leading #| lines with a naive split_once(':') string matcher (no real YAML, no per-option source spans, hardcoded #| prefix) and hand-rewrites the block text. Replace parse_cell_options/partition_options/strip_consumed_lines with quarto_core::cell_options::partition_cell_options (bd-ohvl879u), which provides real YAML parsing, language awareness, and SourceInfo-mapped option spans. Behavior change to review: values like quoted strings / flow collections parse properly instead of string-matching; snapshots may move. Note: faithful source spans for AST-side consumers also want a body-only SourceInfo on CodeBlock (separate strand). Deferred from bd-ohvl879u decision 7.","status":"open","priority":3,"issue_type":"task","created_at":"2026-07-02T17:20:17.970751Z","created_by":"Carlos Scheidegger","updated_at":"2026-07-02T17:20:17.970751Z","dependencies":{"bd-ohvl879u:discovered-from":{"depends_on_id":"bd-ohvl879u","type":"discovered-from","created_at":"2026-07-02T17:20:17.970751Z","created_by":"Carlos Scheidegger"}}}
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 86cf325fa..b731613b2 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -34,6 +34,23 @@
# watcher. Limited to one in-flight process at a time.
quarto-preview-fs-watcher = { max-threads = 1 }
+# Julia-engine e2e tests share ONE ambient transport file
+# (`~/Library/Caches/quarto/julia/julia_transport.txt`): each test uses
+# `setup_julia_project()` (temp project dir, but NOT a temp HOME), so
+# concurrent `daemon: false` runs each boot their own QNR server and
+# overwrite each other's transport entry — a client then reads a stale
+# entry and fails the socket handshake with "Incorrect HMAC digest" at
+# `isopen`. Observed 2026-07-03 (bd-h4rhohhy P3 verification): rotating
+# victims (j1/j2 on one run, j3/j4 on a clean-machine rerun) confirm an
+# intra-suite race, not any single bad test. Serializing the suite is
+# the config-level fix; full hermetic isolation for the j-tests is
+# tracked separately (they belong to the julia-validation plan).
+julia-shared-transport = { max-threads = 1 }
+
[[profile.default.overrides]]
filter = "package(quarto-preview) & binary(integration) & test(/^(staleness|eager_capture|boot)::/)"
test-group = "quarto-preview-fs-watcher"
+
+[[profile.default.overrides]]
+filter = "package(quarto-core) & binary(integration) & test(/^julia_engine_e2e::/)"
+test-group = "julia-shared-transport"
diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml
index 225a9bcbb..98fac2d41 100644
--- a/.github/workflows/test-suite.yml
+++ b/.github/workflows/test-suite.yml
@@ -16,6 +16,9 @@ concurrency:
env:
PANDOC_VERSION: "3.8.3"
+ # Deno version provisioned for the QUARTO_CI assertion test in ts_process.rs.
+ # Must provide `Deno.Command`/`Deno.stdin.isTerminal()` (available since Deno 1.40+).
+ DENO_VERSION: "2.9.0"
jobs:
test-suite:
@@ -119,6 +122,20 @@ jobs:
if: runner.os == 'macOS'
run: brew install minisign
+ # Deno — required for ts_process.rs engine tests and the deno_available_when_quarto_ci
+ # assertion. Plain install (curl + $GITHUB_PATH on Linux; brew on macOS) avoids
+ # /opt/hostedtoolcache/ so the "Free disk space" invariant below is not broken.
+ - name: Set up Deno (Linux)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ curl -fsSL https://deno.land/install.sh | sh -s -- v${DENO_VERSION}
+ echo "$HOME/.deno/bin" >> "$GITHUB_PATH"
+
+ - name: Set up Deno (macOS)
+ if: runner.os == 'macOS'
+ run: brew install deno
+
# Free disk space on Linux runners (14 GB SSD is tight for Rust monorepo).
# `remove_tool_cache: true` is safe — no step in this job uses /opt/hostedtoolcache/
# (no setup-node, setup-python, etc.). See claude-notes/2026-04-28-ci-disk-space-and-profile-ci.md.
@@ -179,6 +196,9 @@ jobs:
run: cargo nextest run --tests --cargo-profile ci
env:
RUSTFLAGS: "-D warnings"
+ # Turns the silent Deno-skip in deno_available_when_quarto_ci into a hard
+ # failure if the "Set up Deno" step above regresses.
+ QUARTO_CI: "1"
wasm-tests:
name: WASM Tests
diff --git a/.github/workflows/ts-test-suite.yml b/.github/workflows/ts-test-suite.yml
index fc5f40296..e711fc2cb 100644
--- a/.github/workflows/ts-test-suite.yml
+++ b/.github/workflows/ts-test-suite.yml
@@ -16,6 +16,8 @@ concurrency:
env:
PANDOC_VERSION: "3.8.3"
+ # Deno version for engine-host-deno vitest + deno-test + bundle freshness gate.
+ DENO_VERSION: "2.9.0"
jobs:
test-suite:
@@ -117,6 +119,14 @@ jobs:
shell: bash
run: npm ci
+ # Deno — needed for engine-host-deno deno-test and the freshness gate.
+ # denoland/setup-deno is safe here: this workflow has no "Free disk space"
+ # step (and no remove_tool_cache invariant to preserve).
+ - name: Set up Deno
+ uses: denoland/setup-deno@v2
+ with:
+ deno-version: ${{ env.DENO_VERSION }}
+
# WASM build for hub-client (must happen before TypeScript build)
- name: Set up Clang (Linux)
if: runner.os == 'Linux'
@@ -145,3 +155,45 @@ jobs:
run: |
cd hub-client
npm run test:ci
+
+ # engine-host-deno: vitest suite (105+ Node-side harness tests)
+ - name: Run engine-host-deno tests (vitest)
+ shell: bash
+ run: npm run test -w @quarto/engine-host-deno
+
+ # engine-host-deno: deno-native test (the one leg that requires Deno)
+ - name: Run engine-host-deno deno test
+ shell: bash
+ run: deno test --allow-all ts-packages/quarto-engine-host-deno/src/deno-host.deno-test.ts
+
+ # plan1a.6 Phase 2 (Deno dial-back) — seam #8: connectControl round-trip
+ # over a REAL loopback socket (Deno.listen({ port: 0 }) in-test); no mock
+ # Deno.Conn. CI-only tier (cannot be a vitest test — the module references
+ # Deno.*). `--sloppy-imports` lets deno's type-checker resolve
+ # @quarto/types' `.js` internal specifiers pulled in transitively via
+ # ./types.ts (same reason the wire-parity step below needs it).
+ - name: Run engine-host-deno control-transport deno test
+ shell: bash
+ run: deno test --allow-all --sloppy-imports ts-packages/quarto-engine-host-deno/src/control-transport.deno-test.ts
+
+ # T-Gate-parity: TS↔Rust wire-dual parity (Plan 2 Phase B gate). Reads the
+ # Rust-serialized fixture (crates/quarto-core/tests/fixtures/ts_wire_parity.json,
+ # produced by the regen-gated #[test] in ts_protocol.rs) and set-equates each
+ # instance's keys against a KEYS list pinned to the TS wire type via
+ # `satisfies`/`_Exhaustive`. `--sloppy-imports` lets deno's type-checker
+ # resolve @quarto/types' `.js` internal specifiers (so the compile guards
+ # are checked here, not just the runtime set-equality). @quarto/types is
+ # mapped in the repo-root deno.jsonc.
+ - name: Run engine-host-deno wire-parity deno test
+ shell: bash
+ run: deno test --allow-all --sloppy-imports ts-packages/quarto-engine-host-deno/src/wire-parity.deno-test.ts
+
+ # Bundle freshness gate: rebuild engine-host-deno.js from source and assert
+ # the committed bytes are unchanged. Fails if someone edited TS but forgot
+ # to run `npm run bundle`. build-info.json is gitignored (volatile builtAt);
+ # the diff targets only the bundle itself.
+ - name: Check engine-host-deno bundle freshness
+ shell: bash
+ run: |
+ npm run bundle -w @quarto/engine-host-deno
+ git diff --exit-code -- ts-packages/quarto-engine-host-deno/dist/engine-host-deno.js
diff --git a/.gitignore b/.gitignore
index b1c5179f1..0540ec1e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,14 @@ node_modules/
**/.quarto/hub/hub.json
ts-packages/*/dist/
ts-packages/*/dist-bundle/
+# Exception: the engine-host-deno bundle is embedded into the q2 binary via
+# include_str! (plan1a-host "Bundle embedding"), so the single bundle file must
+# be tracked even though it lives under an otherwise-ignored dist/. A committed
+# placeholder lets fresh clones compile; Plan 1b overwrites it with the real
+# esbuild output (same path, new bytes). Other dist/ artifacts stay ignored.
+!ts-packages/quarto-engine-host-deno/dist/
+ts-packages/quarto-engine-host-deno/dist/*
+!ts-packages/quarto-engine-host-deno/dist/engine-host-deno.js
ts-packages/*/*.tsbuildinfo
q2-demos/*/dist/
crates/wasm-quarto-hub-client/pkg/
@@ -57,3 +65,10 @@ CLAUDE.local.md
# local-prod mode data directory
.local-prod-data/
+
+# Hermetically-regenerated TS engine extension bundles (built at test time
+# via crate::engine_fixture_build; see plan1c3 Task 6). legacy-python's
+# committed stub is unaffected — a gitignore entry cannot untrack an
+# already-tracked file.
+crates/quarto-core/tests/fixtures/extensions/*/dist/
+resources/extension-build/deno.lock
diff --git a/.superpowers/sdd/1c-task-1-report.md b/.superpowers/sdd/1c-task-1-report.md
new file mode 100644
index 000000000..ccf4e35ce
--- /dev/null
+++ b/.superpowers/sdd/1c-task-1-report.md
@@ -0,0 +1,106 @@
+# Task 1 Report — Engine-contribution data types + static-claim → LanguageClaim conversion
+
+## Status
+
+DONE
+
+## Files Changed
+
+- `crates/quarto-core/src/extension/types.rs` — sole file modified
+
+## What Was Added
+
+### New types
+- `EngineContribution` enum (`External { path, name, claims, file_extensions, claims_files }` + `Reorder { name }`)
+- `StaticLanguageClaim` struct (`kind`, `priority`, `when_class`)
+- `ClaimKind` enum (`Primary`, `Interop`, `Fallback`)
+
+### New field on `Contributes`
+```rust
+pub engines: Vec,
+```
+All three existing `Contributes { .. }` literals already used `..Default::default()`, so no manual updates were needed:
+- `crates/quarto-core/src/filter_resolve.rs:488`
+- `crates/quarto-core/src/transforms/shortcode_resolve.rs:2048`
+- `crates/quarto-core/src/stage/stages/metadata_merge.rs:1630`
+
+### New functions
+- `static_claim_to_language_claim(claim, first_class) -> LanguageClaim`
+- `lookup_static_claim(claims, language, first_class) -> LanguageClaim`
+
+### `LanguageClaim` derives
+Already had `#[derive(Debug, Clone, Copy, PartialEq, Eq)]` at `engine/mod.rs:104` — no changes needed.
+
+## TDD Sequence
+
+**RED** — wrote tests with two stubs:
+- `static_claim_to_language_claim`: converted correctly but ignored `when_class` (always converted)
+- `lookup_static_claim`: always returned `Primary(1)` ignoring map contents
+
+Ran `cargo nextest run -p quarto-core -E 'test(extension::types::tests::static_claim) or test(extension::types::tests::lookup)'`:
+- 5 PASS (positive conversion cases — stubs handled those correctly)
+- 4 FAIL (the required RED cases):
+ - `static_claim_when_class_mismatch_returns_none`: got `Primary(1)`, expected `None`
+ - `static_claim_when_class_mismatch_no_first_class_returns_none`: got `Primary(1)`, expected `None`
+ - `lookup_absent_language_returns_none`: got `Primary(1)`, expected `None`
+ - `lookup_present_mismatched_when_class_returns_none`: got `Primary(1)`, expected `None`
+
+**GREEN** — replaced stubs with correct implementations:
+
+`static_claim_to_language_claim`: added `when_class` guard before the `match`:
+```rust
+if let Some(ref required) = claim.when_class {
+ if first_class != Some(required.as_str()) {
+ return LanguageClaim::None;
+ }
+}
+```
+
+`lookup_static_claim`: proper absent-check + delegation:
+```rust
+match claims.get(language) {
+ None => crate::engine::LanguageClaim::None,
+ Some(claim) => static_claim_to_language_claim(claim, first_class),
+}
+```
+
+## Test Results
+
+### New tests (15 total in `extension::types::tests`)
+```
+cargo nextest run -p quarto-core -E 'test(extension::types)'
+Summary [0.062s] 15 tests run: 15 passed, 2556 skipped
+```
+
+Tests added (9 new, 6 pre-existing):
+1. `static_claim_primary_no_when_class_default_priority` — `Primary(1)` default ✓
+2. `static_claim_primary_no_when_class_explicit_priority` — `Primary(5)` explicit ✓
+3. `static_claim_interop_and_fallback_default_priority` — `Interop(0)`, `Fallback(0)` ✓
+4. `static_claim_when_class_match_converts` — `"marimo"` == `"marimo"` → `Primary(1)` ✓
+5. `static_claim_when_class_mismatch_returns_none` — `"marimo"` != `"python"` → `None` ✓ (P1-14 binding)
+6. `static_claim_when_class_mismatch_no_first_class_returns_none` — `"marimo"` != `None` → `None` ✓ (P1-14 binding)
+7. `lookup_absent_language_returns_none` — absent key → `None` ✓
+8. `lookup_present_matching_when_class_converts` — present + match → converts ✓
+9. `lookup_present_mismatched_when_class_returns_none` — present + mismatch → `None` ✓
+
+Pre-existing tests also updated:
+- `test_contributes_default`: added `assert!(c.engines.is_empty())` ✓
+
+### Broader regression check
+```
+cargo nextest run -p quarto-core -E 'test(extension::) or test(engine::)'
+Summary [6.699s] 460 tests run: 460 passed, 2111 skipped
+```
+
+### Build verification
+```
+cargo build -p quarto-core
+Finished `dev` profile [optimized + debuginfo] target(s) in 2.78s
+```
+No warnings, no errors. All three existing `Contributes` literals compile correctly via `..Default::default()`.
+
+## Notes
+
+- No `serde` derives added (Task 2 owns YAML parsing).
+- No changes to `parse_contributes`, `TsEngine`, or resolution code.
+- `LanguageClaim` needed no derive additions.
diff --git a/.superpowers/sdd/1c-task-11-report.md b/.superpowers/sdd/1c-task-11-report.md
new file mode 100644
index 000000000..099d91292
--- /dev/null
+++ b/.superpowers/sdd/1c-task-11-report.md
@@ -0,0 +1,99 @@
+# Task 11 Report — P2-12 + P2-13
+
+## Summary
+
+Both P2-12 and P2-13 are implemented, tested GREEN, and all 2595 quarto-core
+tests pass. Clippy reports zero warnings.
+
+---
+
+## P2-13 (implemented earlier in this session)
+
+### What changed
+
+`partition_cells` in `crates/quarto-core/src/engine/jupyter/text_execute.rs`
+gained a `multi_engine: bool` third parameter. When `false` (single-engine
+sequence), owned-but-unrunnable cells are passed through unexecuted rather than
+raising `NoHandlerForLanguage`. When `true` (multi-engine), the existing loud
+error fires.
+
+`ExecutionContext` gained `multi_engine: bool` (default `false`) and
+`with_multi_engine(bool)`. `engine_execution.rs` computes `let multi_engine =
+to_run.len() > 1` before the `into_iter()` move and passes it via
+`.with_multi_engine(multi_engine)`.
+
+### Tests (P2-13)
+
+| Test | File | Result |
+|------|------|--------|
+| `test_partition_cells_owned_unrunnable_fails_loudly` | `text_execute.rs` | GREEN (updated to `multi_engine=true`) |
+| `test_partition_cells_single_engine_owned_unrunnable_passthrough` | `text_execute.rs` | GREEN (new, `multi_engine=false` → Ok) |
+| `test_partition_cells_cede` | `text_execute.rs` | GREEN (updated to pass `false`) |
+| `test_partition_cells_execute` | `text_execute.rs` | GREEN (updated to pass `false`) |
+| `test_partition_cells_mixed` | `text_execute.rs` | GREEN (updated to pass `false`) |
+
+---
+
+## P2-12 — Registered owning engine unavailable → loud error
+
+### What changed
+
+`get_engine_with_fallback` in `engine_execution.rs` return type changed from
+`Arc` to `Result, PipelineError>`.
+
+New behaviour matrix:
+
+| Registered? | `is_available()` | In `spliced_engines`? | Result |
+|-------------|------------------|----------------------|--------|
+| Yes | true | — | `Ok(engine)` |
+| Yes | false | No | **`Err(PipelineError::stage_error(...))`** ← P2-12 |
+| Yes | false | Yes | `Ok(markdown)` silently (capture replay) |
+| No | — | No | `Ok(markdown)` + warning |
+| No | — | Yes | `Ok(markdown)` silently |
+
+`run()` now propagates the error via `?` at the call site.
+
+### Blast-radius analysis
+
+One test asserted the now-obsolete silent-fallback contract:
+
+| Test | File | What changed | Why |
+|------|------|-------------|-----|
+| `q2_preview_without_capture_still_warns_unavailable_engine` | `pipeline.rs` | Renamed to `q2_preview_without_capture_errors_unavailable_engine`; assertion changed from "Ok + `not available` warning" → "Err + engine name in message" | Was asserting the old silent-fallback behaviour P2-12 intentionally removes |
+
+Three unit tests call `get_engine_with_fallback` directly (all test the
+UNREGISTERED path, which still returns `Ok`):
+
+| Test | Change | Why |
+|------|--------|-----|
+| `test_engine_fallback_with_unavailable_engine` | Added `.expect()` | Return type changed to `Result` |
+| `test_spliced_engine_suppresses_fallback_warning` | Added `.expect()` | Return type changed to `Result` |
+| `test_unspliced_engine_still_warns_when_sibling_spliced` | Added `.expect()` (both calls) | Return type changed to `Result` |
+
+These tests still exercise unregistered engines (else branch), which return
+`Ok(markdown)` both before and after P2-12 — their semantics did not change.
+
+### New P2-12 tests
+
+| Test | File | RED → GREEN |
+|------|------|-------------|
+| `test_p2_12_owning_engine_unavailable_fails_loudly` | `engine_execution.rs` | RED (result.is_ok(), expected Err) → GREEN |
+| `test_p2_12_spliced_unavailable_engine_still_silent` | `engine_execution.rs` | Was already PASS (spliced path unchanged); GREEN from start |
+
+### Vacuity check
+
+Vacuity revert described in test comment: removing the `is_available()` gate
+(falling back to markdown for ALL registered engines) causes
+`test_p2_12_owning_engine_unavailable_fails_loudly` to fail with "got Ok (old
+silent-fallback behaviour)". Confirmed at RED run before implementation.
+
+---
+
+## Test counts
+
+- Tests added: 2 (P2-12: `test_p2_12_owning_engine_unavailable_fails_loudly`,
+ `test_p2_12_spliced_unavailable_engine_still_silent`)
+- Tests modified: 1 renamed + 1 assertion changed (`q2_preview_without_capture_*`)
+ + 4 `.expect()` additions + 5 P2-13 arg updates
+- Total quarto-core: **2595 passed, 0 failed**
+- Clippy: **0 warnings**
diff --git a/.superpowers/sdd/1c-task-2-report.md b/.superpowers/sdd/1c-task-2-report.md
new file mode 100644
index 000000000..449b499b5
--- /dev/null
+++ b/.superpowers/sdd/1c-task-2-report.md
@@ -0,0 +1,99 @@
+# Task 2 Report — Parse `contributes.engines` + `.js` validation + warning emitter
+
+## Status: DONE
+
+## Commit
+
+`7d0d047ab` on `feature/ts-engine-extensions`
+
+## Files changed
+
+- `crates/quarto-core/src/extension/read.rs` — added `parse_engines`, `parse_external_engine`,
+ `parse_claims_map`, `parse_static_language_claim`, `parse_string_list`; updated
+ `parse_contributes` to call them and extended the "at least one" check; 17 new tests.
+- `crates/quarto-core/src/extension/types.rs` — added `engine_contribution_missing_fields_warning`
+ and its `use quarto_error_reporting::DiagnosticMessage` import; 6 new tests.
+
+## TDD RED → GREEN sequence
+
+### Phase 1 — types.rs warning emitter (P1-11)
+
+**RED**: Added 6 tests in `extension::types::tests` that call
+`engine_contribution_missing_fields_warning`. Compile error:
+```
+error[E0425]: cannot find function `engine_contribution_missing_fields_warning` in this scope
+ --> crates/quarto-core/src/extension/types.rs:395:17
+```
+(6 identical errors, one per call site)
+
+**GREEN**: Implemented the function. Logic: match on `External`, collect which of
+`name`/`claims`/`file_extensions`/`claims_files` are `None` (not `Some(empty)`), format a
+`DiagnosticMessage::warning` naming those fields; return `None` for `Reorder` or a fully-declared
+`External`.
+
+### Phase 2 — read.rs engine parser (P1-8, P1-9, happy path, None/Some(empty), shorthand,
+engines-only)
+
+**RED**: Added 8 new tests in `extension::read::tests`. Compile warnings (unused imports for
+`EngineContribution`/`ClaimKind`/`StaticLanguageClaim`) plus runtime failures — all
+`test_engine_*` tests would panic or error because the engine parsing code didn't exist yet.
+
+**GREEN**: Implemented the full engine parsing chain in `read.rs`.
+
+## Test list and counts
+
+```
+cargo nextest run -p quarto-core -E 'test(extension::)'
+68 tests run: 68 passed, 2517 skipped
+```
+
+New tests added (17 total):
+
+**read.rs (11 new)**
+- `test_engine_ts_path_rejected` (P1-8)
+- `test_engine_uppercase_js_rejected` (P1-9a)
+- `test_engine_mjs_path_rejected` (P1-9b)
+- `test_engine_external_happy_parse`
+- `test_engine_claims_present_but_empty_is_some`
+- `test_engine_absent_optional_fields_are_none`
+- `test_engine_claims_shorthand_forms`
+- `test_engines_only_extension_is_valid`
+
+**types.rs (6 new)**
+- `warning_names_missing_name_field` (P1-11)
+- `warning_names_missing_claims_field` (P1-11)
+- `warning_names_missing_file_extensions_field` (P1-11)
+- `warning_names_missing_claims_files_field` (P1-11)
+- `no_warning_when_all_fields_present_even_empty` (P1-11, Some(empty) = declared)
+- `no_warning_for_reorder_variant` (P1-11)
+
+## Verification commands and output
+
+```
+cargo build -p quarto-core
+ Finished `dev` profile [optimized + debuginfo] target(s) in 3.14s
+
+cargo nextest run -p quarto-core -E 'test(extension::)'
+ Summary [0.253s] 68 tests run: 68 passed, 2517 skipped
+
+cargo nextest run --workspace --exclude wasm-qmd-parser
+ Summary [69.746s] 10474 tests run: 10474 passed, 197 skipped
+```
+
+## Implementation notes
+
+- `parse_static_language_claim` uses `yaml_rust2::Yaml` enum variants directly (Scalar arm
+ pattern-matches on `Boolean(false)`, `Boolean(true)`, `Integer(n)`) since that's what the
+ `ProjectConfig` interpretation context produces.
+- The `fallback` key in `claims` gets special treatment: its object form is `{ priority?: int }`
+ with kind implicitly `Fallback` (no `kind` field in the YAML). Other keys use the full
+ `{ kind, priority?, whenClass? }` form.
+- `file-extensions` and `claims-files` use the hyphen YAML keys; the struct fields use
+ snake_case (`file_extensions`, `claims_files`).
+- The "at least one sub-field" error message was extended to include `engines` in the list.
+- `engine_contribution_missing_fields_warning` writes the message into `w.title` (the
+ `DiagnosticMessage::warning(msg)` API) — tests assert on `w.title.contains("field-name")`.
+
+## Concerns
+
+None. All brief requirements implemented as specified.
diff --git a/.superpowers/sdd/1c-task-5-report.md b/.superpowers/sdd/1c-task-5-report.md
new file mode 100644
index 000000000..1435e798d
--- /dev/null
+++ b/.superpowers/sdd/1c-task-5-report.md
@@ -0,0 +1,49 @@
+# Task 5 Report — Infra leaves
+
+## Status: COMPLETE
+
+## Files changed
+
+- `crates/quarto-util/src/data_dir.rs` — new module: `data_dir_from()` pure helper + `quarto_data_dir()` IO wrapper + 5 tests
+- `crates/quarto-util/src/lib.rs` — added `pub mod data_dir;` + `pub use data_dir::quarto_data_dir;`
+- `crates/quarto-system-runtime/src/traits.rs` — added `is_interactive()` (default `false`) + `running_in_ci()` (default reads `env_get("CI")`) to `SystemRuntime` trait; added 5 tests with `CiMockRuntime` inline mock
+- `crates/quarto-system-runtime/src/native.rs` — added `is_interactive()` override (`std::io::stdin().is_terminal()`); added `is_interactive_native_false_under_nextest` test
+
+## Verification commands and output
+
+```
+cargo nextest run -p quarto-util -p quarto-system-runtime
+```
+
+136 tests run: **136 passed, 0 skipped**
+
+New tests by file:
+- `quarto-util data_dir::tests::data_dir_from_both_none_returns_none` PASS
+- `quarto-util data_dir::tests::data_dir_from_data_dir_branch_last_component_is_quarto` PASS
+- `quarto-util data_dir::tests::data_dir_from_falls_back_to_data_dir_with_quarto_suffix` PASS
+- `quarto-util data_dir::tests::data_dir_from_override_wins_and_is_used_as_is` PASS
+- `quarto-util data_dir::tests::quarto_data_dir_returns_existing_directory` PASS
+- `quarto-system-runtime traits::tests::running_in_ci_true_for_nonempty_value` PASS
+- `quarto-system-runtime traits::tests::running_in_ci_true_for_one` PASS
+- `quarto-system-runtime traits::tests::running_in_ci_false_for_empty_string` PASS
+- `quarto-system-runtime traits::tests::running_in_ci_false_when_not_set` PASS
+- `quarto-system-runtime traits::tests::is_interactive_default_is_false` PASS
+- `quarto-system-runtime native::tests::is_interactive_native_false_under_nextest` PASS
+
+```
+cargo build -p quarto-core
+```
+
+Clean — no existing `impl SystemRuntime` required updating (both new methods have defaults).
+
+## Design decisions
+
+**`QUARTO_DATA_DIR` override semantics**: honored as-is (no `quarto` suffix appended). The `quarto` suffix is only appended to the `dirs::data_dir()` fallback branch. This mirrors Q1's `quartoDataDir()` which treats `QUARTO_DATA_DIR` as the quarto data root directly. Documented in the `data_dir_from` doc-comment and asserted in `data_dir_from_override_wins_and_is_used_as_is`.
+
+**`running_in_ci` test isolation**: used a minimal inline mock (`CiMockRuntime`) that controls only `env_get("CI")`. All other required methods are `unimplemented!()`. This avoids reading or mutating the real process environment — no parallel-test races.
+
+**`is_interactive` true-path not unit-tested**: `NativeRuntime::is_interactive()` delegates to `std::io::stdin().is_terminal()`. The `true` path requires an actual PTY, which nextest does not provide. The test asserts `false` under nextest (correct — no TTY) and notes the true path is not covered by unit tests. This is acceptable: the implementation is a one-liner with no logic to test beyond the bool flip.
+
+## Out of scope
+
+`HostGlobalConfig` construction (Task 7) — not touched. `QUARTO_DATA_DIR` is not consumed anywhere except `quarto_data_dir()`.
diff --git a/.superpowers/sdd/1c-task-6-report.md b/.superpowers/sdd/1c-task-6-report.md
new file mode 100644
index 000000000..493c104f4
--- /dev/null
+++ b/.superpowers/sdd/1c-task-6-report.md
@@ -0,0 +1,66 @@
+# Task 6 Report — Registry primitives: `contribution_order` + engine-shutdown machinery
+
+## Status: COMPLETE
+
+## Commit
+
+`9191eb1b0` on branch `feature/ts-engine-extensions`
+
+## Changes
+
+Three files touched:
+
+### `crates/quarto-core/src/engine/traits.rs`
+Added `ExecutionEngine::shutdown()` default method (no-op, returns `Ok(())`) with full
+idempotency contract documented in the doc comment. Placed after `quarto_required()`,
+before the trait's closing `}`.
+
+### `crates/quarto-core/src/engine/ts_engine.rs`
+Added `TsEngine::shutdown()` override near the `quarto_required()` override (~line 728):
+```rust
+fn shutdown(&self) -> Result<(), ExecutionError> {
+ self.host.shutdown()
+}
+```
+Delegates directly to `TsEngineHost::shutdown()`, which is already idempotent via
+`Option::take()` guards on all subprocess handles.
+
+### `crates/quarto-core/src/engine/registry.rs`
+- Added `use super::ExecutionError;` import.
+- Added `pub contribution_order: Vec` field to `EngineRegistry` struct (with doc
+ comment explaining consumer intent).
+- Initialized `contribution_order: Vec::new()` in all three struct-literal constructors:
+ `new()`, `empty()`, and `with_replay_many()`.
+- Added `shutdown_all(&self) -> Result<(), ExecutionError>` method: best-effort iteration
+ over all engines, returns first error, continues through the rest.
+
+## Tests (TDD — RED then GREEN)
+
+Three tests added to `engine::registry::tests`:
+
+| Test | Gate | Result |
+|------|------|--------|
+| `test_shutdown_all_noop_on_builtins` | always | PASS |
+| `test_contribution_order_roundtrip` | always | PASS |
+| `test_shutdown_all_kills_ts_engine` | `deno_is_available()` | PASS (Deno ran) |
+
+Command:
+```
+cargo nextest run -p quarto-core -E 'test(engine::registry) or test(engine::ts_engine::tests::shutdown)'
+```
+Output: `16 tests run: 16 passed, 2587 skipped`
+
+The Deno-gated test (`test_shutdown_all_kills_ts_engine`) ran and passed. It:
+1. Spawned a real subprocess via `TsEngineHost::start_with_command(sh -c 'cat >/dev/null', ...)`
+2. Asserted `host.is_alive() == true` (exercised-guard)
+3. Wrapped the host in a `TsEngine`, registered it in a `EngineRegistry::empty()`
+4. Called `registry.shutdown_all()`
+5. Asserted `host.is_alive() == false`
+
+Build verification:
+- `cargo build -p quarto-core` — clean (no errors, no warnings)
+- `cargo build -p quarto-core --tests` — clean (no errors, no warnings)
+
+## Concerns
+
+None. Implementation is exactly as specified in the brief. No scope creep.
diff --git a/.superpowers/sdd/1c-task-7b-report.md b/.superpowers/sdd/1c-task-7b-report.md
new file mode 100644
index 000000000..989e2c7e4
--- /dev/null
+++ b/.superpowers/sdd/1c-task-7b-report.md
@@ -0,0 +1,155 @@
+# Task 7b Report — Build real engine registry on ProjectContext
+
+## Status: COMPLETE
+
+Commit: `bd0e7dde9`
+
+---
+
+## Construction sequence as built
+
+All changes in `crates/quarto-core/src/`:
+
+### `lib.rs`
+- Added `pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") }` for `HostGlobalConfig.quarto_version`.
+
+### `project/mod.rs`
+
+**New function `build_engine_registry` (native-only, `#[cfg(not(target_arch = "wasm32"))]`)**
+
+Signature:
+```rust
+fn build_engine_registry(
+ extensions: &[Extension],
+ binary_dependencies: &BinaryDependencies,
+ runtime: &dyn SystemRuntime,
+) -> Result>
+```
+
+Steps implemented exactly per brief:
+
+1. **HostGlobalConfig**: `resource_dir` from `BUILTIN_EXTENSIONS.path()` (empty if None), `runtime_dir`/`data_dir` from `quarto_util::quarto_runtime_dir()`/`quarto_data_dir()` (IO errors propagated as `QuartoError`), `pandoc_path` from `binary_dependencies.pandoc`, `is_interactive_session`/`running_in_ci` from `runtime`, `quarto_version` from `crate::version()`.
+2. **`Arc::new(global)`** — NOT spawned (cheap; no subprocess).
+3. **`EngineRegistry::new()`** — built-ins markdown/knitr/jupyter.
+4. **Per-extension contribution loop**:
+ - `Reorder { name }` → push name to `order` vec (no register).
+ - `External { path, name, claims, file_extensions, claims_files }`:
+ - 4a: `!path.exists()` → `Err("…no bundled .js file… Run 'q2 build-ts-extension'…")`
+ - 4b: `key = name.unwrap_or(ext.id.to_string())`, `name_declared = name.is_some()`
+ - 4c: `registry.has_engine(&key)` → collision `Err("…both '{}' and '{}'…")` naming both contributors (tracked in `key_to_contributor: HashMap`, built-ins pre-seeded as "built-in")
+ - 4d: `TsEngine::new(…)`, `registry.register(Arc::new(engine))`, push `key` to `order`
+ - 4e: `engine_contribution_missing_fields_warning(…)` → push to `registry.diagnostics` if Some
+5. **`contribution_order`**: dedup first-occurrence from `order`. Comment left for Task 9 `_quarto.yml` engines splice.
+6. **Validation**: for each name in `contribution_order`, if `!registry.has_engine(name)` → `Err("'{}' was specified in the list of engines… Available engines are: …")` (sorted, joined).
+7. **Return** `Arc::new(registry)`. Comment: `// Task: drain registry.diagnostics at orchestrator (plan step 10)`.
+
+**`ProjectContext::discover` updated**:
+- Captures `single_file_input = input_file.clone()` before `input_file` is consumed into `files`.
+- Computes `binary_dependencies` before the registry build.
+- `discovery_anchor`: single-file → `single_file_input` (file path, `start_dir = dir`); project → `dir.join("_quarto.yml")` (parent = `dir`, so `start_dir = dir`).
+- `builtin_dir`: native = `BUILTIN_EXTENSIONS.path()`, WASM = None.
+- Calls `discover_extensions(anchor, project_dir_opt, builtin_dir, runtime)`.
+- Native: `registry = build_engine_registry(&extensions, &binary_dependencies, runtime)?`.
+- WASM: `registry = Arc::new(EngineRegistry::new())`.
+
+**`ProjectContext::single_file` updated**:
+- Same pattern: compute `binary_dependencies`, `builtin_dir`, `extensions`, then `registry` (native/WASM gated).
+
+---
+
+## Seams bound
+
+| Seam | Test | Status |
+|------|------|--------|
+| P1-1: engine registered | `p1_1_extension_engine_appears_in_engine_names` | BOUND |
+| P1-5 (reg half): declared name + zero-spawn | `p1_5_named_engine_registered_without_spawn` | BOUND |
+| P1-6 (alias reg half): ext-id key | `p1_6_unnamed_engine_registered_under_ext_id` | BOUND |
+| P1-4: collision names both contributors | `p1_4_name_collision_errors_and_names_both_contributors` | BOUND |
+| P1-3: unknown reorder lists available | `p1_3_unknown_reorder_hint_errors_listing_available` | BOUND |
+| P1-2: contribution_order populated | `p1_2_contribution_order_contains_declared_engines` | BOUND |
+| Warning: missing static fields in diagnostics | `warning_missing_static_fields_appears_in_diagnostics` | BOUND |
+| Bundle-missing: Err mentions build-ts-extension | `bundle_missing_errors_with_build_ts_extension_hint` | BOUND |
+
+## Seams deferred (per brief)
+
+- **P1-7**: name-mismatch fires at first LoadEngine — Task 14 / mock-load test.
+- **P1-5 full resolution** (engine: echo with no-spawn resolution) — Task 9.
+
+---
+
+## Test counts
+
+- New tests: **8** (all in `engine_registry_build.rs` behind `#[cfg(not(target_arch = "wasm32"))]`)
+- Total quarto-core tests: **2578 passed, 0 failed, 33 skipped**
+
+---
+
+## Pre-existing test interactions
+
+No pre-existing tests tripped the new validation. Checked: `cargo nextest run -p quarto-core` ran 2578 tests with 0 failures. The existing `project_pipeline` and related tests use temp dirs without `_extensions/` subdirectories, so `discover_extensions` returns an empty vec and `build_engine_registry` produces the same built-ins-only registry as before.
+
+---
+
+## Exact commands + output
+
+```
+cargo build -p quarto-core → Finished (no errors/warnings)
+cargo build -p quarto-core --tests → Finished (no errors/warnings)
+cargo nextest run -p quarto-core -E 'test(engine_registry_build)'
+ → 8 tests run: 8 passed, 2603 skipped
+cargo nextest run -p quarto-core
+ → 2578 tests run: 2578 passed, 33 skipped
+```
+
+---
+
+## Notes
+
+- **`quarto_version`**: uses `quarto-core`'s `CARGO_PKG_VERSION` (not the `quarto` binary crate). Both track the workspace release version — acceptable per brief.
+- **WASM path**: `build_engine_registry` is native-only; WASM `discover` / `single_file` keep `EngineRegistry::new()` (built-ins only). Extension discovery runs on WASM but only format/filter contributions matter there.
+- **`discovery_anchor` for multi-file projects**: uses `dir.join("_quarto.yml")` whose parent is `dir`, ensuring `discover_extensions` starts its walk at the project root. File need not exist; `Path::parent()` is purely path arithmetic.
+
+---
+
+## Fix pass (review findings) — commit `8d20b9def`
+
+### Changes
+
+**`crates/quarto-core/src/project/mod.rs`**
+
+1. **Lazy host construction** (`build_engine_registry`):
+ - Extracted `any_external_engine(extensions: &[Extension]) -> bool` (native-only, `#[cfg(not(target_arch = "wasm32"))]`): scans the extension list for at least one `EngineContribution::External`. `Reorder`-only and empty lists return `false`.
+ - Restructured `build_engine_registry`: the `needs_host = any_external_engine(extensions)` predicate gates the entire `HostGlobalConfig` / `TsEngineHost` construction block. When `false`, only Reorder hints are harvested — `quarto_runtime_dir()` / `quarto_data_dir()` are never called.
+ - All existing step numbering and behavior preserved for the `needs_host = true` path.
+
+2. **Shared discovery helper** (`discover_extensions_and_build_registry`):
+ - New function factoring the `builtin_dir` + `discover_extensions` + `build_engine_registry` (or `EngineRegistry::new()` on WASM) block, called by both `discover` and `single_file`.
+ - Eliminates ~14 duplicated lines.
+
+3. **No change to the WASM path**: the new helper correctly uses `Arc::new(EngineRegistry::new())` on wasm32.
+
+**`crates/quarto-core/tests/integration/engine_registry_build.rs`**
+
+- **`p0_no_extension_project_builds_builtins_only`**: new integration test — project with `_quarto.yml` but no `_extensions/` → `discover` succeeds, registry contains all three built-ins, `contribution_order` is empty.
+- **`p1_5_named_engine_registered_without_spawn`**: added comment explaining the test binds only REGISTRATION (not spawn-count), and that the no-spawn guarantee is structural + covered by TsEngine unit tests (T4 P1-12).
+
+**`crates/quarto-core/src/project/mod.rs` (unit tests)**
+
+- **`needs_host_tests` submodule** (4 tests, `#[cfg(not(target_arch = "wasm32"))]`):
+ - `needs_host_false_for_no_extensions` — `any_external_engine(&[])` returns false.
+ - `needs_host_false_for_reorder_only` — Reorder-only extension → false.
+ - `needs_host_true_for_external_engine` — External engine → true.
+ - `needs_host_true_when_external_mixed_with_reorder` — External + Reorder → true.
+
+### Covering tests + result
+
+```
+cargo nextest run -p quarto-core -E 'test(engine_registry_build)'
+ → 9 tests run: 9 passed (was 8; p0_no_extension_project_builds_builtins_only added)
+
+cargo nextest run -p quarto-core -E 'test(needs_host)'
+ → 4 tests run: 4 passed (all new any_external_engine predicate tests)
+
+cargo nextest run -p quarto-core
+ → 2583 tests run: 2583 passed, 33 skipped (was 2578; +5 new tests)
+```
diff --git a/.superpowers/sdd/1c-task-9-report.md b/.superpowers/sdd/1c-task-9-report.md
new file mode 100644
index 000000000..bdb14a736
--- /dev/null
+++ b/.superpowers/sdd/1c-task-9-report.md
@@ -0,0 +1,129 @@
+# Task 9 Report — Make `resolve_engines` DRIVE execution
+
+## Status: COMPLETE
+
+## Test summary
+
+```
+cargo nextest run -p quarto-core
+Summary [20.811s] 2586 tests run: 2586 passed, 33 skipped
+```
+
+`cargo clippy -p quarto-core --all-targets` — 0 errors, 0 warnings after fixing the `mut` lint on `raw_explicit`.
+
+## Changes made
+
+### 1. `EngineExecutionStage::run` — execution driven by `resolution.sequence`
+
+**File:** `crates/quarto-core/src/stage/stages/engine_execution.rs`
+
+- Removed the `detect_engine_sequence` call at step 1; `resolve_engines` now receives `ctx.claimed_engine_name.as_deref()` instead of `None`.
+- The `to_run` loop iterates `resolution.sequence` (each `DetectedEngine`) rather than the old `sequence.engines`.
+- Removed the `dropped_duplicates` warning loop — `resolve_engines` returns a de-duplicated sequence by construction.
+- The fast path (empty `to_run` → passthrough) is preserved.
+- `handled_languages_for` + `.with_handled_languages` wiring is unchanged (P2-8 already wired).
+
+### 2. `resolve_engines` claimed short-circuit (P2-10)
+
+**File:** `crates/quarto-core/src/engine/resolution.rs`
+
+Added at the very top of `resolve_engines`, before any tier logic:
+
+```rust
+if let Some(name) = claimed {
+ return EngineResolution {
+ sequence: vec![DetectedEngine::new(name)],
+ ownership: LinkedHashMap::new(),
+ };
+}
+```
+
+Deleted the old seed handling (`explicit_with_seed`/`seed` contributed to `present`). Simplified `is_implicit` to `!has_engine_key && raw_explicit.is_empty()` (the `claimed.is_none()` clause is gone — the short-circuit above makes it unreachable).
+
+Fixed `mut raw_explicit` → `raw_explicit` (clippy lint).
+
+### 3. `contribution_order` in `candidate_engines`
+
+**File:** `crates/quarto-core/src/engine/resolution.rs`
+
+Added a splice between the explicit list and `BUILTIN_ORDER`:
+
+```rust
+for name in ®istry.contribution_order {
+ let name = name.as_str();
+ if !seen.contains(name) && registry.has_engine(name) {
+ seen.insert(name);
+ order.push(name);
+ }
+}
+```
+
+Extension engines registered via `registry.register()` are now promoted ahead of `knitr`/`jupyter`/`markdown` in the candidate order. The `is_implicit` gate is unchanged — auto-promotion does not disable T4.
+
+### 4. Delete `KNOWN_ENGINES` / `is_known_engine`
+
+**File:** `crates/quarto-core/src/engine/detection.rs`
+
+- Deleted `KNOWN_ENGINES` const.
+- Deleted `is_known_engine` function.
+- Deleted `test_is_known_engine` and `test_detect_engine_top_level_key` / `test_detect_engine_top_level_knitr` tests (replaced by resolver-level tests for top-level key via registry).
+- The top-level-key scan in `detect_engines` now uses `registry.engine_names()` (passed in as a slice) instead of `KNOWN_ENGINES`.
+
+**File:** `crates/quarto-core/src/engine/mod.rs`
+
+- Removed `KNOWN_ENGINES` and `is_known_engine` from re-exports.
+
+## New tests added (in `resolution.rs`)
+
+All binding the seams called out in the brief:
+
+- **P2-1** — `{julia}` cells + julia `Primary(1)` engine → `sequence == [julia]`
+- **P2-2** — `engine: markdown` on a doc with `{r}` cells → `sequence == [markdown]` (explicit beats knitr tier)
+- **P2-4** — `{notaknownlang}` cell, no claimer → `sequence == [jupyter]` (implicit-Fallback)
+- **P2-5** — no executable cells → `sequence` is empty
+- **P2-7** — `{r}`+`{python}` → `sequence == [knitr]`, `ownership[python] == knitr` (Interop)
+- **P2-9** — pure `{python}`, no python extension → `sequence == [jupyter]` (knitr absent, presence-gated)
+- **P2-10** — `claimed = Some("echo")`, front-matter `engine: knitr`, `{echo}`+`{python}` cells → `sequence == [echo]`; `engine: knitr` ignored; `{python}` NOT owned by a second engine
+- **contribution_order auto-promotion** — unlisted extension engine with same-kind/same-priority claim as built-in wins tiebreak by contribution_order position
+- **top-level key via registry** — top-level `:` key with extension engine registered selects that engine
+
+## Existing tests updated
+
+All existing engine-execution and preview-record tests that relied on the engine sequence being driven by metadata alone (without code cells) were updated to reflect the Task 9 behavioral change: **engines only appear in the sequence if they claim at least one cell language from the ORIGINAL AST.**
+
+The core change: every mock/probe/passthrough engine needs `claims_language` implemented, AND every test document needs code cells for the engine to claim.
+
+### `engine_execution.rs` test updates
+
+- `MockIncludesEngine` and `MockAppendingEngine`: added `claims_language` returning `Primary(1)` for their own language names.
+- `test_unknown_engine_falls_back`: removed the diagnostic assertion — with no cells, the sequence is empty and no warning fires (correct new behavior: no cells = nothing to execute = no warning).
+- `test_duplicate_engine_dedups_and_warns`: removed the "Duplicate engine 'fixture-a'" diagnostic assertion — de-duplication now happens silently in `candidate_engines`.
+- `test_two_engines_run_in_sequence_with_handoff`, `test_multi_engine_trace_records_per_engine_snapshots_and_captures`, `test_multi_engine_record_then_replay_is_byte_clean`: rewritten — the "engine A generates engine B cells at runtime" handoff pattern is incompatible with resolution-driven execution (sequence is fixed from original AST). Both `{fixture-a}` and `{fixture-b}` cells are now present in the ORIGINAL document.
+- Several other tests: added `{engine-name}` cells to content.
+
+### `preview_record.rs` test updates
+
+- `PassthroughTestEngine`: added `claims_language` returning `Primary(1)` for `"test-passthrough"`. Test content already had `{test-passthrough}` cells; this was the only missing piece.
+
+### `replay_engine.rs` integration test updates
+
+- `capture_engine_input` helper's `ProbeEngine`: added `claims_language` returning `Primary(1)` for `self.name`.
+- `replay_capture_in_options_overrides_engine_through_render_to_file`: added `{replay-only-engine-4b}` cell to QMD file content. The `capture_engine_input` probe now runs and captures the serialized QMD; the replay pass matches it and returns the recorded markdown.
+- `replay_capture_miss_surfaces_as_render_error`: added `{replay-only-engine-4b}` cell. `ReplayEngine` is now in the sequence (it already had `claims_language`), runs, finds `input_qmd` mismatch → "replay miss" error as expected.
+
+### `pipeline.rs` test updates
+
+- `test_render_qmd_to_html_uses_replay_registry_from_config`: `ProbeEngine` now has `claims_language`; content updated with `{replay-only-engine}` cell. Two-pass probe+replay pattern still works.
+- `q2_preview_without_capture_still_warns_unavailable_engine`: **strategy changed** from using unregistered `replay-only-engine` to a purpose-built `AlwaysUnavailableEngine` (registered, `is_available()=false`, `claims_language("always-unavailable")=Primary(1)`). This tests the behavior deterministically regardless of whether R/Python runtimes are installed. Content updated with `{always-unavailable}` cell and custom registry passed via `engine_registry` parameter.
+
+### `project_resources.rs` integration test updates
+
+- `orchestrator_drains_replay_engine_report_to_output_dir`: `ProbeEngine` now has `claims_language` for `"replay-real-pipeline-engine"`; QMD file content updated with `{replay-real-pipeline-engine}` cell. The probe captures the new serialized input (with cell); the replay capture's `result.markdown` is unchanged (engine output replaces the cell with processed markdown).
+
+## Deferred: `set_project` / per-render `EngineProjectContext`
+
+Per the brief, the per-render `EngineProjectContext` setup (`set_project` on TS engines before `execute`) is deferred to a follow-up / Plan 4. It is **inert for Plan 1c's tests** (echo ignores project context; `ensure_launched` uses `unwrap_or_default()`). This is a known Phase-2 completeness gap, NOT a silent omission — noted here explicitly.
+
+## Concerns
+
+None. All 2586 tests pass; 0 clippy warnings.
diff --git a/.superpowers/sdd/task-p0-report.md b/.superpowers/sdd/task-p0-report.md
new file mode 100644
index 000000000..5a4186452
--- /dev/null
+++ b/.superpowers/sdd/task-p0-report.md
@@ -0,0 +1,407 @@
+# Task P0 report — reproduce Bug A / Bug B / Bug C (bd-h4rhohhy)
+
+**Status: DONE.** All three defects reproduced deterministically on this machine
+(2026-07-02). Harnesses committed (diagnosis-only; zero product changes). Fix
+SHAPES proposed below for the controller checkpoint — **no fixes implemented.**
+
+Commit: `2931d7692 test(preview-capture): P0 repro harnesses for Bug A/B/C (bd-h4rhohhy)`
+
+Files:
+- `crates/quarto-core/tests/integration/ts_process_framing_probe.rs` (Bug C, new)
+- `crates/quarto-core/tests/integration/main.rs` (register, +1 line)
+- `crates/quarto-core/tests/integration/julia_engine_e2e.rs` (PC4a / Bug A)
+- `q2-preview-spa/e2e/engine-capture-splice.spec.ts` (PC5 / Bug B, new)
+
+---
+
+## Verdict table
+
+| Bug | Reproduced? | Deterministic? | Root cause status |
+|-----|-------------|----------------|-------------------|
+| A (close/busy) | YES (verbatim below) | YES | Root-caused (plan + confirmed live) |
+| B (capture → pane) | YES, in **chromium** | YES (fails by timeout every run) | Localized to delivery chain; NOT Bug C. Precise link = P2 |
+| C (wire framing) | Reader framing triaged | YES (3 probes) | Reader escalation confirmed; leak source engine-side (P1) |
+
+**Does Bug C explain Bug B? NO — definitively.** PC5 reproduces Bug B with the
+**echo** engine, which spawns no julia child, emits no `ts_process` error, and
+the capture IS recorded server-side. Bug C and Bug B are independent defects
+(see §Bug B and §Bug C).
+
+---
+
+## Bug A — oneShot close hits a busy worker → capture discarded
+
+**Reproduced: YES, verbatim.** Deterministic via two concurrent oneShot renders
+of one sleeping-cell doc sharing one julia server (the second render's pre-run
+`close` collides with the first's still-running worker). Proven live via the
+`q2` binary under an isolated HOME (never touching the user's real server
+pid 9828); codified as `pc4a_shared_server_busy_close` (in-process,
+`#[ignore]` + `QUARTO_PC4A_LIVE=1`, isolated HOME).
+
+### Repro transcript (binary probe, 2026-07-02)
+
+Isolation env (protects the user's server + reuses the pre-instantiated depot):
+```
+HOME= JULIA_DEPOT_PATH=~/.julia
+QUARTO_JULIA_PROJECT=~/Library/Caches/quarto/julia
+PATH=:$PATH # NOT the juliaup shim (drifts under temp HOME)
+```
+Doc `sleepy.qmd`: `engine: julia`, `execute: {daemon: false}`, cell `sleep(25)\n1 + 1`.
+Render A (background) starts the isolated server (transport up in 4s), worker
+busy on `sleep(25)`; render B (same file, +6s) → **verbatim**:
+
+```
+Rendering single file: …/sleepy.qmd
+Error: Execution failed in julia: Julia server returned error after receiving "close" command:
+
+Failed to close notebook: …/sleepy.qmd
+
+The underlying Julia error was:
+
+Tried to close file "…/sleepy.qmd" but the corresponding worker is busy.
+
+1 error
+```
+
+This is the exact user-reported failure. The pre-run close is
+`executeJulia` julia-engine.ts:703-718 (`isopen`→`close`); there is no
+busy handling anywhere in the engine (plan grep). The error propagates through
+`render_to_file` and the whole render/capture is discarded.
+
+### Root cause (verified file:line, per plan + confirmed live)
+`~/src/quarto-julia-engine/src/julia-engine.ts`:
+- pre-run close: `:703-718` (oneShot/restart → `isopen`→`close`, no busy guard)
+- post-run close: `:742-749` (oneShot → `close`, no busy guard — latent: can
+ discard a capture whose run SUCCEEDED)
+- `startOrReuseJuliaServer` `:330-448` reuses ANY existing transport file
+ regardless of `oneShot` (`:440-446`), so a busy/orphaned shared worker is
+ reached by a fresh oneShot render.
+
+### Proposed fix SHAPE (needs controller ratification before PC4 freeze)
+- **PC1** (post-run close, `:742-749`): a post-run close failure after a
+ successful run must be **non-fatal** — warn + return the run result.
+- **PC2** (pre-run close, `:703-718`): a pre-run close-busy must NOT surface a
+ bare protocol error. Either **recover** or **fail with an actionable message**
+ naming the stale-server/transport remedy.
+ - **Concrete recovery lead for P1:** julia-engine.ts already exposes a
+ forceful close — the CLI `close` command calls `closeWorker(file, force)`
+ with a `--force` option (`:~1002-1003`). QNR therefore supports a forced
+ close; the pre-run close could pass `force: true` (or fall back to it on
+ busy). P1 should confirm the QNR socket-command surface for `close` accepts
+ a force flag before committing to recovery-vs-actionable-message.
+- **Frozen PC4 post-fix assertion (controller signs off at fix time):** render B
+ either SUCCEEDS via forced close, or FAILS with the PC2 actionable-remedy
+ substring — never the bare `"worker is busy"` protocol error. (My harness's
+ pre-fix assertion is `msg.contains("worker is busy")`; the fix flips it.)
+
+---
+
+## Bug B — recorded capture never reaches the browser pane
+
+**Reproduced: YES, in chromium** (the user saw it in Firefox; it reproduces in
+chromium too — a notable finding, it is NOT browser-specific). Deterministic:
+`engine-capture-splice.spec.ts` (PC5) fails by timeout on every run.
+
+### Repro transcript (PC5, `test.fail()` temporarily disabled to harvest evidence)
+
+Real `q2 preview` + chromium, temp project with the committed **echo** engine
+and `index.qmd` containing one `{echo}` cell with source `PC5_ECHO_SOURCE_TOKEN`.
+The pane renders the INERT source, then times out (15s) waiting for the executed
+marker `ECHO_EXECUTED`:
+
+```
+Error: pane must show the executed echo marker after the capture splices in; pane text was:
+
+ PC5 echo capturePC5 headingPC5_ECHO_SOURCE_TOKEN
+
+console:
+[log] WASM module initialized successfully, template loaded
+[log] Waiting for peer connection...
+[log] Peer connected - online mode
+[warning] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.
+
+Expected: true
+Received: false
+```
+
+Server side (independent manual `q2 preview` run, `RUST_LOG=quarto_preview=debug`)
+— the capture IS recorded and the sidecar written:
+```
+INFO quarto_preview::capture_driver: recorded engine capture(s) rel_path=index.qmd engines=echo
+INFO quarto_preview::capture_driver: recorded engine captures count=1
+# data-dir/captures/.bin written (gzip EngineCapture)
+```
+
+### Boundary evidence / where it breaks
+- **Server**: capture recorded + `IndexDocument::set_capture` writes the sidecar
+ (`capture_driver.rs:184-205`). WORKING.
+- **Browser**: SPA WASM initialized, **"Peer connected - online mode"** (samod
+ sync is up), doc rendered — but the pane shows only the inert source, and
+ there is **no capture-related console log and no error**. The executed marker
+ never splices in.
+- Because the eager capture is recorded at server startup (before the browser
+ connects), the SPA should receive it via the **initial** `onCapturesChange`
+ (`quarto-sync-client/src/client.ts:779-781` / `:1351-1353`, fired off the
+ IndexDocument's `captures` map). Captures ride on the IndexDocument
+ (`getCapturesFromIndex`, `:339-364`); the SPA is synced to that doc (it
+ rendered), so the sidecar entry should be visible on first fire.
+
+**Conclusion: Bug B is an independent delivery-chain defect, NOT Bug C.** Root
+cause NOT determined at P0 (that is P2's job); it is localized to the
+`set_capture → samod → onCapturesChange → PreviewApp → getBinaryDocById → WASM
+splice` chain, browser-side of "Peer connected". Candidate links for P2, in
+descending suspicion:
+1. The **capture BINARY doc** (a separate samod doc referenced by
+ `captureDocId`, written by `write_capture_doc` capture_driver.rs:326) is not
+ synced/resolvable to the SPA — `getBinaryDocById` (client.ts:~1007-1019)
+ returns nothing and the splice silently no-ops (consistent with "no error,
+ no log").
+2. Initial `onCapturesChange` fires before the render is ready and the
+ `contentTick` bump (PreviewApp.tsx:729-738) is lost / the render effect
+ (`:1005-1030`) does not re-fire.
+3. `state.activeFile` key vs the sidecar `rel_path` key mismatch in the render
+ effect (plan candidate).
+
+### Proposed fix SHAPE
+Deferred to P2 by design (plan §P2: "location unknown until P0"). P2's entry
+point = PC5's failure + this boundary evidence; recommended first step is
+targeted SPA instrumentation (console.log at `onCapturesChange`,
+`getBinaryDocById`, and the render effect — **rebuild the SPA per the binding
+rules**, revert before commit) to identify the silent link, then a minimal fix
+on that link. **Frozen post-fix assertion = PC5 as written** (`ECHO_EXECUTED`
+appears in the pane without reload; remove `test.fail()`), plus the julia leg
+PC6 and the jsdom-tier PC7.
+
+---
+
+## Bug C — engine-host stdout wire-frame corruption
+
+**Reader framing triaged (the P0 mandate); leak source is engine-side (P1).**
+Three deterministic probes (`ts_process_framing_probe.rs`, deno-gated), all GREEN:
+
+```
+PASS ts_process_framing_probe::pc_c_a_large_single_line_frame_parses
+PASS ts_process_framing_probe::pc_c_b_foreign_line_is_malformed
+PASS ts_process_framing_probe::pc_c_b_prime_interleaved_bytes_corrupt_frame
+3 tests run: 3 passed
+```
+
+### Findings
+- **(a) Large-frame suspect RULED OUT.** A >1 MB single-line frame parses to
+ `Ok(Response)` — `BufRead::read_line` has no size cap and loops the 8 KB
+ BufReader buffer to the terminating `\n`. The reader does NOT truncate or
+ mis-split large frames. So the "legit executeResult frame rejected" symptom
+ is NOT a large-frame reader bug.
+- **(b) Foreign / interleaved bytes → `RecvError::Malformed`.** A stray ANSI
+ julia log line on the wire (symptom #1) and foreign bytes spliced into a
+ frame's middle (symptom #2) both fail framing at `StdioReadHalf::recv`
+ (ts_process.rs:292-308). Both live symptoms are the **same** root cause: a
+ foreign writer on the engine-host's stdout fd.
+- **Catastrophic escalation (by reading, ts_process.rs:930-954):** a single
+ `Malformed` makes `reader_loop` set `shutting_down`, **broadcast an error to
+ EVERY pending slot, and kill the whole Deno subprocess.** One stray line
+ destroys the entire engine host and every in-flight capture. This is why the
+ user "sometimes sees no result" — the executeResult is dropped AND the host
+ dies.
+
+### Leak source (engine-side; candidate, not the P0 mandate)
+- **Ruled out for the live session:** the `Deno.stdout.writeSync` sites in
+ julia-engine.ts (`:1035` `logStatus`, `:1056` `printJuliaServerLog`) are
+ **Cliffy CLI subcommand handlers**, not the engine-host module path — they do
+ not fire when julia-engine.ts is imported as an engine.
+- **Candidate:** `start_quartonotebookrunner_detached.jl` runs
+ `run(detach(cmd), wait = false)` with **no stdio redirection**, so the
+ detached QNR server inherits fds; its startup banner (`[ Info: Log started
+ at …`) can land on an inherited stdout. Precise mechanism is engine-side
+ forensics owned by P1 — not required to close the P0 reader triage.
+
+### Proposed additive seam row (controller sign-off required)
+
+| ID | Tier | Real unit | Seam → assertion | Mock boundary | Revert hunk → RED |
+|----|------|-----------|------------------|---------------|-------------------|
+| PC-C | int-rs, deno-gated (framing) + unit-ts (demux resilience) | `StdioReadHalf::recv` framing + `reader_loop` Malformed arm | (framing, DONE) >1MB frame → `Ok`; foreign/interleaved line → `Malformed`. (resilience, POST-FIX) a stray non-JSON line does NOT kill the host and does NOT fail an unrelated in-flight request; a following valid frame is still delivered | none (real pipe) for framing; MockReadHalf for resilience | revert the log-and-skip resilience hunk → one stray line kills all pending → RED |
+
+### Proposed fix SHAPE (two independent, both need sign-off)
+1. **Engine-side (P1/upstream) — the root fix:** ensure NO child process
+ inherits the engine-host's stdout fd. The detached julia launcher must
+ redirect the server's stdout/stderr to its log file / devnull rather than
+ inherit, so a QNR banner can never reach the protocol channel.
+2. **Reader-side defense-in-depth (quarto-core, `reader_loop:930-954`) —
+ POLICY CHANGE, explicit sign-off:** make the reader resilient to a stray
+ non-protocol line (bounded log-and-skip) instead of `Malformed → kill-all`,
+ so one leaked banner does not discard every in-flight capture. This changes
+ the current "compromised channel ⇒ kill subprocess" contract; the comment at
+ `:930-935` (finding #7, "one terminal error per exit") documents the
+ intent, so a change here must be deliberate.
+
+---
+
+## Ruled-out hypotheses (evidence, not assumption)
+- **"Large executeResult frame is rejected by the reader."** RULED OUT — probe
+ `pc_c_a` shows a 2 MB single-line frame parses to `Ok`.
+- **"Bug C is the root of Bug B."** RULED OUT — PC5 reproduces Bug B with echo
+ (no julia child, no `ts_process` error, capture recorded server-side).
+- **"Bug B is Firefox-specific."** RULED OUT as a necessary condition — it
+ reproduces in chromium.
+- **"The julia-engine.ts `Deno.stdout.writeSync` calls corrupt the wire in the
+ live session."** RULED OUT — they are CLI-only, not on the engine module path.
+
+## Constraints honored
+- No product code changed (harnesses/probes only; `git status` clean but for the
+ 4 committed files). Never pushed.
+- PC4a ran only under an isolated temp HOME; its julia server (pid 1478) was
+ killed after the probe. The user's server (pid 9828) and the pre-existing
+ leaked workers (bd-l9jhy5u0; they use the user's transport file / TempDir
+ projects, not my isolated HOME) were left untouched.
+- PC5 uses `test.fail()`; PC4a uses `#[ignore]` + `QUARTO_PC4A_LIVE` opt-in;
+ the Bug C probe is deno-gated and GREEN — none break the default suites.
+- SPA + WASM + q2 binary were rebuilt (per the binding rebuild rules) before the
+ browser-tier PC5 run; the first run had shown the placeholder SPA.
+
+---
+
+# Fix wave (review response — task-p0-review.md, 2026-07-02)
+
+Addresses the four Important findings. Still P0 (harnesses/diagnosis only; the
+one product-file touch — PreviewApp.tsx instrumentation — was quarantined and
+reverted; `git status` shows no product changes).
+
+## Fix #1 + #2 — PC4a rewritten to the specified scenario, with cleanup
+
+`pc4a_shared_server_busy_close` → **`pc4a_abandoned_worker_close_busy`**. Now
+matches the plan §P0 spec exactly:
+- Drives **`record_capture`** (`quarto_core::engine::preview_record::record_capture`),
+ not `render_to_file` — the soft-fail caller path the bug actually lives on.
+- The first run's client is **ABANDONED mid-run**: its Deno engine-host is
+ killed (identified by the isolated-HOME bundle path in its cmdline) while the
+ worker is provably executing, closing the QNR socket → EPIPE → the worker is
+ left **orphaned-busy** (QNR does not cancel the task). Then a **fresh**
+ `record_capture` of the same doc → oneShot pre-run close hits the abandoned
+ worker.
+- **Real signal, not a timer** (addresses the Minor): the cell writes a sentinel
+ file before sleeping; the harness abandons the client only after the sentinel
+ appears (worker provably mid-run). The old flat `sleep(6)` guess is gone.
+- **Cleanup (Fix #2):** an `IsolatedJuliaServerGuard` Drop kills the detached
+ server's **process group** (pid read from the isolated transport file) on
+ scope exit, **even on panic** — so no server leaks per run. `#[cfg(unix)]`
+ gated (process groups); `#[ignore]` + `QUARTO_PC4A_LIVE=1` opt-in unchanged.
+
+### New verbatim failure (record_capture #2, live 2026-07-02)
+```
+Stage 'engine-execution' failed: Execution failed in julia: Julia server returned error after receiving "close" command:
+
+Failed to close notebook: /var/folders/…/T/.tmpncK0gc/sleepy.qmd
+
+The underlying Julia error was:
+
+Tried to close file "/var/folders/…/T/.tmpncK0gc/sleepy.qmd" but the corresponding worker is busy.
+```
+Note the entry point is now `Stage 'engine-execution' failed` (the
+`record_capture` pipeline), not a CLI render — the correct soft-fail path.
+
+### Cleanup verified
+`pgrep -f quartonotebookrunner.jl | wc -l` = **4 before, 4 after** the run; the
+only survivors use the user's transport file (pre-existing bd-l9jhy5u0 workers).
+The isolated server the harness started was killed by the guard — no net leak.
+
+### Fix-shape update (scenario distinction, per review #1)
+The **PC4 frozen assertion targets the ABANDONED-worker scenario**: the fresh
+`record_capture` either RECOVERS via a forceful close (→ succeeds) or fails with
+the actionable PC2 remedy — never the bare `"worker is busy"`. Force-close is
+correct here **because the worker is abandoned**. The **concurrent-live-render**
+case (where force-close would harm a legitimate in-flight execution) is
+explicitly OUT of PC4's scope — it is the plan's documented-not-gold-plated
+`oneShot`-server-reuse design question for the upstream PR (plan §P1). P1 must
+not let a force-close recovery kill a *live* peer's worker; scoping the recovery
+to detectably-abandoned workers (or to the actionable-message path) is the safe
+default.
+
+## Fix #3 — PC5 sync-client state harvested (re-ranks the P2 candidates)
+
+Quarantined instrumentation (reverted before commit) stashed every
+`onCapturesChange` payload on `window.__pc5CaptureLog` and logged the render
+effect's capture lookup; `window.__renderTicks` (a production counter) gave the
+render count. Harvested from the PC5 failure:
+
+```
+sync-client state:
+{
+ "captureLog": [
+ { "keys": ["index.qmd"],
+ "captures": { "index.qmd": { "captureDocId": {"val":"31WYwMd3ZW8Xv92aSghTL6oT1xLo"},
+ "staleness": false } } }
+ ],
+ "renderTicks": 1
+}
+console:
+ PC5-DIAG onCapturesChange ["index.qmd"]
+ PC5-DIAG renderEffect {"activeFile":"index.qmd","captureKeys":["index.qmd"],
+ "hasRef":true,"docId":"31WYwMd3ZW8…","gotBinary":true,"bytes":567}
+```
+
+**This CHANGES the P2 candidate ranking decisively.** The entire delivery chain
+is confirmed WORKING, end to end:
+- `onCapturesChange` fired with the entry keyed `index.qmd` (sidecar synced to
+ the SPA). ⇒ RULES OUT "sidecar not delivered".
+- `activeFile == "index.qmd" == sidecar key` ⇒ RULES OUT the activeFile-vs-rel_path
+ key-mismatch candidate.
+- `hasRef: true` ⇒ the capture ref resolved in state.
+- `gotBinary: true, bytes: 567` ⇒ `getBinaryDocById` SUCCEEDED and the gzipped
+ capture bytes were fetched ⇒ RULES OUT "capture binary doc not synced/resolvable"
+ (my prior #1 candidate).
+- `renderTicks: 1` ⇒ the render effect fired (once, WITH the capture present) ⇒
+ RULES OUT "contentTick effect not re-firing".
+
+`renderPageForPreview("index.qmd", undefined, captureGzJson[567])` was therefore
+called **with** the capture bytes, yet the pane rendered the **inert** source
+(no `ECHO_EXECUTED`). **The break is inside the WASM `render_page_for_preview`
+ReplayEngine splice**, downstream of everything the delivery chain does.
+
+### Revised P2 candidate ranking (Bug B)
+1. **PRIMARY — WASM ReplayEngine splice rejects/ignores the capture.** Strongest
+ sub-candidate: the **canonical `input_qmd` staleness check in WASM replay**
+ (the plan's explicitly "accepted-untested" item) rejects the capture on a
+ byte mismatch between the recorded `input_qmd` and what the WASM recomputes,
+ silently falling back to the default markdown engine → inert render. (Note the
+ sidecar's own `staleness:false` is the SERVER's flag; the WASM replay applies
+ its OWN canonical-input check — they are independent.) The 567 bytes reaching
+ WASM but no splice is exactly this signature.
+2. Secondary — ReplayEngine construction from `captureGzJson` (gunzip/parse)
+ fails silently WASM-side.
+
+Bug B's fix therefore most likely lands in **quarto-core's WASM replay path**
+(`render_page_for_preview` / `ReplayEngine`), requiring a hub WASM rebuild
+(plan §P2). This retires the plan's "add a seam only if P0 diagnosis proves it's
+Bug B's cause" condition on the canonical-input staleness seam: **P0 now
+implicates it** — P2 should add that seam.
+
+
+## Fix #4 — full `npm run test:e2e` proves the new spec doesn't break the suite
+
+Ran the entire suite once (`q2` re-embedded from the reverted/clean SPA source,
+verified `strings target/debug/q2 | grep -c PC5-DIAG` = 0):
+
+```
+37 passed (28.9s)
+1 failed
+```
+
+- **PC5 (`engine-capture-splice.spec.ts`, #30) is among the 37 passed** — its
+ `test.fail()` records the pre-fix RED as an EXPECTED failure; it is NOT in the
+ failures detail section.
+- The **1 failed is pre-existing and orthogonal**: `firefox-ws-queue.spec.ts`
+ under the **firefox** project fails to launch because Firefox is not installed
+ on this machine (`browserType.launch: Executable doesn't exist … firefox-1522/
+ firefox/Nightly.app`). The SAME spec **passes under chromium** (#31 ✓). My
+ changes touch nothing in that spec. The brief provisioned chromium only.
+
+Net: the new PC5 spec does not break the default e2e suite.
+
+## Files (fix wave)
+- `crates/quarto-core/tests/integration/julia_engine_e2e.rs` — PC4a rewritten
+ (`pc4a_abandoned_worker_close_busy`): `record_capture` + client-abandonment +
+ sentinel signal + `IsolatedJuliaServerGuard` cleanup.
+- `q2-preview-spa/e2e/engine-capture-splice.spec.ts` — PC5 now also harvests
+ `renderTicks` (+ `__pc5CaptureLog` when diagnostic instrumentation is present).
+- `q2-preview-spa/src/PreviewApp.tsx` — instrumentation was added, harvested,
+ and **reverted** (no net change; confirmed clean in the binary).
diff --git a/.superpowers/sdd/task-p1-report.md b/.superpowers/sdd/task-p1-report.md
new file mode 100644
index 000000000..5e148efba
--- /dev/null
+++ b/.superpowers/sdd/task-p1-report.md
@@ -0,0 +1,258 @@
+# Task P1 report — Bug A fix + Bug C engine root + PC4 RED→GREEN (bd-h4rhohhy)
+
+**Status: DONE_WITH_CONCERNS.** Both engine defects fixed upstream on
+`q2-close-busy-fix` (not pushed), wired into the q2 fixture, PC1/PC2/PC4
+RED→GREEN proven. One concern: the PC4a *live harness* isolation is imperfect
+on macOS (see §7).
+
+- Upstream `~/src/quarto-julia-engine` `q2-close-busy-fix` @ **93bce7b** —
+ "Recover from busy/failed oneShot worker close; redirect detached server stdio"
+- q2 worktree `braid/bd-h4rhohhy-q2-preview-engine-capture` @ **4efa5be84** —
+ "Wire Bug A/C engine fix into julia fixture; flip PC4a to recovery (bd-h4rhohhy)"
+
+---
+
+## 1. Decision gate — QNR force-close surface (work item 2)
+
+**Answer: YES — QNR exposes a forceful close.** Evidence (file:line, upstream):
+- `julia-engine.ts:774` — `ServerCommand` union includes
+ `| { type: "forceclose"; content: { file: string } }`
+- `julia-engine.ts:783` — response map `forceclose: { status: true }`
+- `julia-engine.ts:1100-1106` — `closeWorker(file, force)` sends
+ `type: force ? "forceclose" : "close"`; the CLI `close --force` help reads
+ "This will terminate the worker if it is running."
+- **Live confirmation:** the existing upstream smoke test
+ `force-closing a running worker` is GREEN.
+
+**Selected decision branch: YES = recovery** (per the plan's ratified rule).
+Pre-run close falls back to `forceclose` on busy; **frozen PC4 assertion = the
+fresh `record_capture` SUCCEEDS with a real capture.** The answer fits the YES
+branch cleanly — no NEITHER-branch ambiguity, no controller escalation needed.
+Countersign requested on this evidence.
+
+---
+
+## 2. PC1/PC2 — upstream deno TDD (work items 3, 4)
+
+**Seam:** the close orchestration was extracted into a pure `src/worker-close.ts`
+module (`preRunClose` / `postRunClose` over an injectable `CloseCommandWriter`),
+so the busy-recovery logic is unit-testable with a mocked command writer —
+exactly the frozen "QNR socket/`writeJuliaCommand`" boundary. `executeJulia` now
+calls these helpers through a thin adapter over `writeJuliaCommand`.
+
+Tests: `tests/unit/julia-engine/worker-close.test.ts` (6 tests). `run-tests.{sh,ps1}`
+now discover `tests/unit/` alongside `smoke/`.
+
+### RED (pre-fix extraction reproduces Bug A at the unit tier)
+
+The module was first written as a verbatim extraction of the inline `:703-718` /
+`:742-749` logic (no busy handling). Both busy tests failed with the exact QNR
+message:
+
+```
+running 6 tests from ./tests/unit/julia-engine/worker-close.test.ts
+PC1: a busy post-run close after a successful run is non-fatal ... FAILED
+PC2: a busy pre-run close recovers via forceclose ... FAILED
+...
+error: Error: Julia server returned error after receiving "close" command:
+ ...
+ Tried to close file "/tmp/sleepy.qmd" but the corresponding worker is busy.
+ at postRunClose (src/worker-close.ts:40:9)
+ at preRunClose (src/worker-close.ts:31:11)
+FAILED | 4 passed | 2 failed
+```
+
+### GREEN (fix applied)
+
+```
+running 6 tests from ./tests/unit/julia-engine/worker-close.test.ts
+PC1: a busy post-run close after a successful run is non-fatal (warns, resolves) ... ok
+PC1: a clean post-run close does not warn ... ok
+PC2: a busy pre-run close recovers via forceclose and does not throw ... ok
+PC2: a non-busy pre-run close error is NOT swallowed (propagates, no forceclose) ... ok
+PC2: a closed (not-open) file skips the close entirely ... ok
+isWorkerBusyError matches the QNR busy message and nothing else ... ok
+ok | 6 passed | 0 failed
+```
+
+- **PC1** (post-run, `:742-749`): a failed cleanup close after a *successful*
+ run warns (`quarto.console.warning`) and returns the result — non-fatal. Mock
+ fails ONLY the close (honors the vacuity note structurally: `postRunClose`
+ never sends `run`, and `executeJulia` still awaits the run directly, so run
+ errors cannot be swallowed).
+- **PC2** (pre-run, `:703-718`): a busy `close` falls back to `forceclose`
+ (frozen assertion = the sequence `isopen→close→forceclose` and no throw). A
+ **non-busy** close error still propagates (binds the `isWorkerBusyError`
+ scoping — reverting the guard reddens this test).
+
+Full upstream suite (`tests/run-tests.sh`): **9 passed** (2 existing smoke
+suites incl. `force-closing a running worker` + 6 new unit tests), 0 failed.
+
+---
+
+## 3. Bug C engine-side root fix (work item 5)
+
+`start_quartonotebookrunner_detached.jl`:
+`run(detach(cmd), wait=false)` → `run(pipeline(detach(cmd), stdout=devnull,
+stderr=devnull), wait=false)`. The detached QNR server no longer inherits the
+launcher's (hence the Deno engine-host's) stdout/stderr, so its early output
+can't land on the JSON protocol channel. `quartonotebookrunner.jl` still logs to
+its own `logfile` via its internal pipe → no diagnostics lost. Redirecting to
+the logfile instead would race QNR's own `open(logfile,"w")` truncation, so
+devnull is the conflict-free choice.
+
+**No deno-mock regression test** — the launcher is a Julia subprocess and the
+fd-inheritance behavior is an OS/Julia-runtime property, not mockable in deno.
+Covered by: the PC-C framing probes (GREEN at P0) + PC4a live GREEN end-to-end
+(the whole capture survives through the real launcher). Windows path (PowerShell
+`Start-Process -WindowStyle Hidden`) does not inherit stdio → unaffected, left
+as-is.
+
+---
+
+## 4. Rebundle into q2 (work item 6)
+
+- Upstream rebundle: `quarto call build-ts-extension src/julia-engine.ts`
+ (78 modules, 45323 B). Verified fix markers present (`forceclose`,
+ "returning results anyway", 2× `devnull`).
+- Fixture rebundle: copied `src/julia-engine.ts`, new `src/worker-close.ts`,
+ and `start_quartonotebookrunner_detached.jl` into
+ `crates/quarto-core/tests/fixtures/extensions/julia-engine/`; rebuilt via the
+ compat-log §4 temp-symlink workaround (`q2 build-ts-extension
+ _extensions/julia-engine`, symlink removed after).
+- **Byte-identity property survives:** the q2 build and the Q1 `quarto call`
+ build of the fixed source produce **identical** bundles —
+ `82bff64cc5d060cb48983945060a6932`, 45323 B (was `d9d5120…`, 44512 B).
+- Engine-host bundle NOT rebuilt (no engine-host TS changed).
+
+---
+
+## 5. PC4 freeze + RED→GREEN (work item 7)
+
+`pc4a_abandoned_worker_close_busy` (julia_engine_e2e.rs) assertion flipped from
+`expect_err("worker is busy")` to the **frozen YES-branch**: `record_capture`
+#2 must **succeed**, and (non-vacuous) the returned julia capture's
+`result.markdown` must contain `cell-output` (proves the recovered run actually
+executed the cell). Docstring updated to record the selected branch.
+
+### RED — flipped assertion vs the PRE-fix bundle (`d9d5120…`)
+
+```
+thread 'pc4a_abandoned_worker_close_busy' panicked at julia_engine_e2e.rs:1046:9:
+PC4a: fresh record_capture against the ABANDONED busy worker must SUCCEED
+post-fix (pre-run close recovers via forceclose); got Err:
+Stage 'engine-execution' failed: Execution failed in julia: Julia server
+returned error after receiving "close" command:
+...
+Tried to close file ".../sleepy.qmd" but the corresponding worker is busy.
+Summary [21.4s] 1 test run: 0 passed, 1 failed
+```
+
+### GREEN — flipped assertion vs the FIXED bundle (`82bff64…`)
+
+```
+PASS [83.253s] (1/1) quarto-core::integration julia_engine_e2e::pc4a_abandoned_worker_close_busy
+Summary [83.254s] 1 test run: 1 passed (1 slow), 393 skipped
+```
+
+(83s because the recovered #2 actually runs the cell, which sleeps 60s.)
+Run under `QUARTO_PC4A_LIVE=1` + isolation env (real julia 1.11.7,
+`JULIA_DEPOT_PATH`, `QUARTO_JULIA_PROJECT`). User server pid 9828 verified
+ALIVE before and after both runs.
+
+---
+
+## 6. Docs (work item 8)
+
+- **Compat log §15** (new): supersedes §4/§5's zero-change/byte-identity claim;
+ documents Bug A (decision gate + PC1/PC2 fix), Bug C (devnull redirect), the
+ full upstream diff summary, the **oneShot-reuse design question** for the
+ upstream PR, testing, and the harness-isolation concern.
+- **Migration guide**: headline "zero source changes" corrected with an UPDATE
+ banner + a closing caveat — porting took zero changes, but q2's harder
+ `preview` exercise later exposed two latent *engine* bugs (present in Q1 too)
+ that required source changes. Framed as engine maintenance, not q2 adaptation.
+- **Plan §P1** checkboxes reconciled (all three checked with outcome notes).
+
+---
+
+## 7. Concern — PC4a live-harness isolation is imperfect (macOS)
+
+The harness isolates via a temp `HOME`, but the julia runtime/transport dir
+resolves to `QUARTO_JULIA_PROJECT` (the shared real `~/Library/Caches/quarto/
+julia`), so the transport file is **not** actually isolated. The GREEN run
+spawned QNR servers on the *shared* transport, and the temp-HOME-reading cleanup
+guard missed them (6 leaked). **Cleaned up manually:** killed my 6 servers by
+PID (process groups), removed the stale transport entry they left (it pointed at
+a server I'd killed; the file read empty pre-test). **User server pid 9828 was
+never touched** (verified alive throughout); other agents' pre-existing pool
+(bd-l9jhy5u0) and a teammate's concurrent `quarto-core` run were left alone.
+
+This is a **harness** defect, not a product defect, and it does not undermine
+the RED/GREEN evidence (the close/busy→forceclose recovery was exercised and
+observed). But the harness needs a real runtime-dir override (or an explicit
+skip) before it can run safely unattended. Recommend a follow-up (relates to
+bd-l9jhy5u0). Documented in compat log §15.
+
+---
+
+## 8. Verification counts (work item 9)
+
+| Suite | Result |
+|-------|--------|
+| Upstream deno (`tests/run-tests.sh`) | 9 passed, 0 failed (incl. 6 new PC1/PC2) |
+| q2 `cargo nextest run -p quarto-core` | 2633 passed, 34 skipped, 0 failed (incl. live j1..j6 on rebundled fixture) |
+| q2 `cargo nextest run -p quarto-preview` | 87 passed, 1 skipped, 0 failed |
+| PC4a live (`QUARTO_PC4A_LIVE=1`) | RED (pre-fix) → GREEN (fixed) |
+
+Constraints honored: neither repo pushed; path-scoped commits in each; upstream
+edits only on `q2-close-busy-fix`; frozen seams strengthened (never weakened);
+`feature/ts-engine-extensions`, marimo, and unrelated julia processes untouched.
+
+### Report-scope correction (review Minor)
+
+For full honesty: commit **4efa5be84** (§4 above) also carried the controller's
+P0/P2 checkbox-reconciliation edits to the plan file that were sitting
+uncommitted in the worktree — not only my own §P1 edits. Likewise the fix-wave
+commit below carries the controller's PC4 **countersign** note and the P3
+"PC6 + PC4a shared-transport isolation" item (added to the plan file by the
+controller, uncommitted in the worktree). These are disclosed here rather than
+silently bundled.
+
+---
+
+# Fix wave (review response — task-p1-review.md, 2026-07-02)
+
+Addresses the one Important + one Minor.
+
+## Important — forceclose-itself-failing now bound + documented
+
+Added a **7th** PC2 unit test
+(`tests/unit/julia-engine/worker-close.test.ts`): mocked writer with
+`close → busy`, `forceclose → rejects`; asserts the forceclose error
+**propagates with its real message** (`assertRejects(..., forcecloseError)`),
+is not swallowed or retried, and the command sequence stops at
+`["isopen","close","forceclose"]` (no run attempted). Added a one-line contract
+comment at the forceclose call site in `worker-close.ts`:
+
+> Last line of defense. If the forced close ITSELF fails, that is a genuine
+> environment failure (control server unreachable, etc.) — let it propagate;
+> do not swallow or retry.
+
+No behavior change (binds existing behavior). **Fail-on-revert proven:** wrapping
+the forceclose in a swallowing `try/catch` reddened ONLY this test
+(`6 passed | 1 failed`); restored → `7 passed | 0 failed`. Full upstream suite:
+**10 passed** (7 unit + 3 smoke steps), 0 failed.
+
+**Bundle unchanged (recorded honestly):** the comment + test did **not** change
+`julia-engine.js` — `deno bundle` strips comments and the test is not bundled.
+Both the upstream and q2-fixture bundles stay `82bff64cc5d060cb48983945060a6932`
+(45323 B). No fixture bundle rebundle was needed; only the fixture's
+`src/worker-close.ts` was synced for source parity. Compat log §15 updated.
+
+## Minor — see "Report-scope correction" above.
+
+## Fix-wave commits
+- upstream `q2-close-busy-fix` @ **697a462** — "Bind + document the
+ forceclose-itself-fails contract (review follow-up)"
+- q2 @ (see final message) — fixture `worker-close.ts` parity + docs/plan.
diff --git a/.superpowers/sdd/task-p1c-report.md b/.superpowers/sdd/task-p1c-report.md
new file mode 100644
index 000000000..9c00ace6f
--- /dev/null
+++ b/.superpowers/sdd/task-p1c-report.md
@@ -0,0 +1,257 @@
+# Task P1c report — Bug C reader-side resilience (quarto-core ts_process, seam PC-C resilience leg)
+
+**Status: DONE.** The engine-host stdout reader (`reader_loop`,
+`crates/quarto-core/src/engine/ts_process.rs`) now log-and-skips up to a
+bounded number of consecutive non-JSON stray lines instead of escalating a
+single stray line into a whole-subprocess kill that broadcasts an error to
+every in-flight request. TDD RED→GREEN proven; the frozen P0 framing probes
+stay untouched and GREEN; `quarto-core` + `quarto-preview` nextest suites are
+fully green (2721 passed, 0 failed, 35 skipped).
+
+File touched: `crates/quarto-core/src/engine/ts_process.rs` (only file changed
+— path-scoped, no other file in the tree modified).
+
+---
+
+## 1. TDD RED transcript
+
+Two tests were added (replacing the stale `test_malformed_distinct_from_crash`,
+whose name/assertions described exactly the escalate-on-first-line behavior
+this task changes):
+
+- `test_stray_lines_below_bound_are_skipped_not_fatal` — the discriminating
+ test: two unrelated in-flight requests (A, B); two stray non-JSON lines
+ land on the shared channel between them; both A and B must still receive
+ their own real responses, and `shutting_down` must stay `false`.
+- `test_malformed_beyond_bound_escalates_distinct_from_crash` — the
+ invariant-preservation test: `MAX_CONSECUTIVE_MALFORMED_LINES + 1`
+ consecutive stray lines (no valid frame in between) must still escalate to
+ the pre-existing kill-channel behavior (`Other`, not `ProcessCrashed`;
+ `shutting_down` set).
+
+Supporting test infra: `MockState.malformed` was widened from
+`Option` to a `VecDeque`, and a new
+`MockWriteHalf::signal_malformed_many(&[&str])` was added so a test can queue
+N consecutive stray lines **atomically under one lock** — avoiding a race
+where the reader thread could drain a `signal_malformed` call before a
+second one is queued (the queue is what makes the below/above-bound split
+testable at all).
+
+Ran against the **unmodified** `reader_loop` (constant
+`MAX_CONSECUTIVE_MALFORMED_LINES` declared but not yet wired into the match
+arm):
+
+```
+$ cargo nextest run -p quarto-core --lib -- \
+ ts_process::tests::test_stray_lines_below_bound_are_skipped_not_fatal \
+ ts_process::tests::test_malformed_beyond_bound_escalates_distinct_from_crash
+
+ Summary [ 0.225s] 2 tests run: 1 passed, 1 failed, 2272 skipped
+ FAIL (2/2) quarto-core engine::ts_process::tests::test_stray_lines_below_bound_are_skipped_not_fatal
+```
+
+Failure (RED for the right reason — the discriminating test failed on the
+exact old-code escalation path, not a typo/compile error):
+
+```
+thread '' panicked at crates/quarto-core/src/engine/ts_process.rs:1940:13:
+A must still receive its own response after unrelated stray lines: Err(Other(
+ "engine-host protocol error: non-JSON line on stdout (likely a stray
+ console.log/console.info in the engine): \"[ Info: Log started at
+ 2026-07-02T13:11:20.379\""
+))
+```
+
+(The `test_malformed_beyond_bound_escalates_distinct_from_crash` test passed
+even pre-fix — expected, since old code already escalates on the *first*
+malformed line, so N≥1 stray lines trivially also escalate. That test exists
+to lock the invariant across the refactor, not to redden; the discriminating
+RED evidence is the first test above.)
+
+## 2. Implementation (minimal)
+
+`reader_loop` now tracks a local `consecutive_malformed: u32` counter:
+
+- Reset to `0` on every well-formed frame (the `Ok(Response {..})` arm).
+- On `RecvError::Malformed(line)`: increment the counter, log an `ERROR` with
+ a 200-char excerpt and the `n/BOUND consecutive` count. If
+ `consecutive_malformed <= MAX_CONSECUTIVE_MALFORMED_LINES`, `continue` the
+ loop — `shutting_down`/`pending`/`child` are left untouched. Otherwise, fall
+ through to the **unchanged** pre-existing escalation: set `shutting_down`,
+ drain and error every pending slot, kill the child, `break`.
+
+No other logic changed (routing, EOF/crash, I/O-error arms are untouched).
+
+## 3. TDD GREEN transcript
+
+```
+$ cargo nextest run -p quarto-core --lib -- \
+ ts_process::tests::test_stray_lines_below_bound_are_skipped_not_fatal \
+ ts_process::tests::test_malformed_beyond_bound_escalates_distinct_from_crash
+
+ Starting 2 tests across 1 binary (2272 tests skipped)
+ PASS [ 0.086s] (1/2) quarto-core engine::ts_process::tests::test_malformed_beyond_bound_escalates_distinct_from_crash
+ PASS [ 0.171s] (2/2) quarto-core engine::ts_process::tests::test_stray_lines_below_bound_are_skipped_not_fatal
+ Summary [ 0.174s] 2 tests run: 2 passed, 2272 skipped
+```
+
+## 4. Chosen bound + rationale
+
+`const MAX_CONSECUTIVE_MALFORMED_LINES: u32 = 5;`
+
+- Small enough that a channel producing genuine, sustained garbage (wrong
+ binary spawned, protocol version mismatch, a child that never stops writing
+ to the shared fd) is still caught and terminated quickly — within 6 bad
+ lines, not an unbounded amount of silently-dropped protocol traffic.
+- Large enough to absorb the two concrete P0 live symptoms with margin: (a) a
+ single leaked ANSI julia startup banner line, (b) one executeResult frame
+ corrupted mid-flight by an interleaved foreign write (which framing-splits
+ into at most a couple of bad lines, per the `pc_c_b_prime_interleaved_bytes_
+ corrupt_frame` probe). Both are single-digit events, not sustained streams.
+- The counter is **consecutive**, resetting on every well-formed frame — so a
+ channel that's mostly healthy but occasionally emits one stray line (e.g. a
+ future engine with a similar transient-leak bug) never accumulates toward
+ the bound across its lifetime; only a *burst* of bad lines with no good
+ frame in between trips escalation.
+
+This is a judgment call, not a value derived from a hard constraint; if a
+future symptom needs a larger/smaller bound, this is a one-line change with a
+name attached (`MAX_CONSECUTIVE_MALFORMED_LINES`), not a design change.
+
+## 5. No-hang investigation (brief item 2)
+
+**Question:** what happens to an in-flight request whose response frame was
+itself corrupted/consumed as one of the skipped stray lines — does it hang
+forever now that a single stray line no longer broadcasts an error to every
+pending slot?
+
+**Finding: `TsEngineHost::request()` already has its own per-request timeout
+mechanism, independent of the reader thread and unaffected by this change.**
+
+`request()` (ts_process.rs, unchanged by this task) takes a `window:
+Option` and loops on `rx.recv_timeout(tick)` on the **caller's own
+thread** — polling `cancellation.is_cancelled()` and `start.elapsed() >= w`
+every tick (`CANCEL_TICK` = 250ms, or the window itself if shorter). When the
+window elapses, it fires a cooperative `Cancel{target}` and returns
+`Err(ExecutionError::Timeout{..})`. This loop does **not** depend on the
+reader thread doing anything — even if the reader were fully blocked, the
+caller's own timer still fires.
+
+Auditing every `request()` call site:
+
+| Call site | `window` |
+|---|---|
+| `ts_engine.rs:270` (dynamic `ClaimsLanguage` validation) | `Some(10s)` |
+| `ts_engine.rs:317` (`ClaimsFile` validation) | `Some(10s)` |
+| `ts_engine.rs:638`, `:704`, `:766` | `Some(10s)` |
+| `ts_engine.rs:815` | `Some(30s)` |
+| `load_engine` / `launch_engine` (internal) | `Some(DISCOVERY_WINDOW)` = `Some(10s)`, hardcoded |
+| `ts_engine.rs:740` (**`Execute`** — the long-running user render/capture path, the one Bug C's symptom #2 corrupted frame hit) | `ctx.execute_timeout` |
+
+`ctx.execute_timeout` (`crates/quarto-core/src/engine/context.rs:110`, wired
+from `resolve_execute_timeout` in `engine_execution.rs:597`) resolves from
+document metadata `execute.timeout`:
+
+| metadata | window |
+|---|---|
+| absent / `true` (**default**) | `Some(DEFAULT_EXECUTE_TIMEOUT)` = `Some(300s)` |
+| integer `N` | `Some(N seconds)` |
+| `false` (**explicit opt-out**) | `None` |
+
+**Conclusion:**
+- In the **default configuration** (no `execute: timeout: false` in the doc),
+ every request — including `Execute`, the path Bug C's corrupted-frame
+ symptom hit — is bounded by a caller-side timeout (300s default, or a
+ user-configured integer). A corrupted/dropped frame under the new
+ bounded-skip policy causes that one request to time out after its window
+ and return `Err(Timeout)`, exactly as any other slow/silent engine would.
+ This is a strict improvement over the pre-fix behavior: previously the
+ *entire host* died immediately (destroying every sibling in-flight
+ request too); now only the one request whose frame was actually lost is
+ affected, and only after its own timeout.
+- **Concern (explicit, per brief item 2 — not fixed, not scope-expanded):**
+ if a user explicitly sets `execute: timeout: false`, `request()`'s `window`
+ is `None`. In that mode `request()` still polls `cancellation.is_cancelled()`
+ every 250ms but has **no time-based bound at all**. If that specific
+ request's response frame is the one that gets corrupted/dropped as a
+ skipped stray line, and no *additional* stray lines subsequently arrive to
+ trip `MAX_CONSECUTIVE_MALFORMED_LINES` (which would broadcast an error to
+ it via the escalation path), that request now hangs until explicit
+ cancellation (e.g. the user aborting the preview/render) — there is no
+ automatic recovery. Before this change, ANY single stray line anywhere on
+ the channel would have unblocked it (at the cost of also killing every
+ other in-flight request). This is a narrow, `execute: timeout: false`-gated
+ regression in "self-heals eventually" behavior, traded for the much more
+ common-case win of not destroying unrelated in-flight work over one leaked
+ banner line. I have **not** built new timeout machinery for this — per the
+ brief's explicit instruction not to invent one without reporting first.
+ If the controller wants this closed, the narrowest fix would be: give
+ `None`-window requests an internal maximum wait distinct from
+ "no timeout" (e.g. still respect `MAX_CONSECUTIVE_MALFORMED_LINES` but also
+ cap total wall-clock wait even absent stray lines) — but that is a genuine
+ design decision (what should "no timeout" mean when the channel is
+ degraded but not dead?) and is out of this task's scope.
+
+## 6. WASM-applicability note
+
+`ts_process.rs` line 23: `#![cfg(not(target_arch = "wasm32"))]` — the entire
+module (including `reader_loop`, the new constant, and the mock test infra)
+is **compiled out on `wasm32` targets**. This crate/module is native-only
+subprocess/thread infrastructure; it has no `wasm32`-visible code path. Per
+the WASM rules (`.claude/rules/wasm.md`), no `npm run build:wasm` or hub
+WASM rebuild was required or performed for this change — confirmed by
+inspection of the `#![cfg(...)]` gate, not just assumption.
+
+## 7. Verification counts
+
+```
+$ cargo nextest run -p quarto-core --lib
+ Summary [ 9.733s] 2251 tests run: 2251 passed, 23 skipped
+
+$ cargo nextest run -p quarto-core -p quarto-preview
+ Summary [ 50.505s] 2721 tests run: 2721 passed, 35 skipped
+```
+
+The three frozen P0 framing probes (`ts_process_framing_probe.rs`) ran as
+part of the combined suite and are unmodified and GREEN:
+
+```
+PASS quarto-core::integration ts_process_framing_probe::pc_c_b_foreign_line_is_malformed
+PASS quarto-core::integration ts_process_framing_probe::pc_c_a_large_single_line_frame_parses
+PASS quarto-core::integration ts_process_framing_probe::pc_c_b_prime_interleaved_bytes_corrupt_frame
+```
+
+Also ran (not requested by the brief, but touching shared infra warranted the
+extra check): `cargo clippy -p quarto-core --lib --tests -- -D warnings`.
+One pre-existing failure in `crates/quarto-core/tests/integration/
+julia_engine_e2e.rs:1053` (`clippy::map_unwrap_or`) — **this file was not
+touched by this task** (confirmed via `git status`/`git diff --stat`: the
+only modified file in the tree is `ts_process.rs`); it predates this change
+and is unrelated to it. No clippy warnings were reported for `ts_process.rs`
+itself. `cargo fmt -p quarto-core -- --check` is clean (the repo's post-edit
+hook runs `cargo fmt` automatically).
+
+## 8. Contract comment update (brief item 3)
+
+The `:930-935`-era comment ("Set shutting_down FIRST so the kill below
+doesn't re-enter the crash path (finding #7 — one terminal error per exit)")
+was rewritten in place. The new comment (at the top of the `Malformed` arm)
+explains: what the old policy was and why it was too aggressive (Bug C —
+engine-side leaks, e.g. a detached child inheriting the host's stdout fd, can
+inject stray lines; killing all pending work over one banner is worse than
+skipping it), what the new bounded policy is, and that the original
+kill-everything behavior is deliberately preserved unchanged beyond the
+bound (still needed to catch a genuinely broken wire). The original
+"finding #7" rationale for the shutting_down-before-kill ordering is kept,
+relocated to sit directly above the `shutting_down.store(true, ...)` line it
+actually applies to now (only reached in the escalation branch).
+
+## Not done / out of scope (confirmed correctly excluded)
+
+- The engine-side root fix (detached QNR launcher stdio redirection) — P1's
+ job, already landed upstream per the plan; not touched here.
+- Un-freezing/modifying the P0 framing probes — explicitly frozen; untouched
+ and still green.
+- Any new timeout machinery for the `execute: timeout: false` + corrupted-
+ frame edge case (§5 concern) — reported, not implemented, per brief
+ instruction.
diff --git a/.superpowers/sdd/task-p2-report.md b/.superpowers/sdd/task-p2-report.md
new file mode 100644
index 000000000..a0aee5818
--- /dev/null
+++ b/.superpowers/sdd/task-p2-report.md
@@ -0,0 +1,182 @@
+# Task P2 report — Bug B (bd-h4rhohhy): "Bug B" refuted; echo fixture fixed; PC5/PC-B/PC7 green, PC6 deferred
+
+**Status: DONE_WITH_CONCERNS.** The evidenced defect was fixed (echo fixture emits realistic
+`::: {.cell}` wrappers), all in-scope tiers are green with fail-on-revert proofs, and "Bug B" as a
+distinct browser/splice defect is REFUTED. Concerns: PC6 (julia browser leg) is deferred opt-in
+because a temp HOME does not isolate the julia transport; and I leaked a few orphaned julia servers
+I could not reap (safety classifier blocked the kill). Details below. No push.
+
+## Diagnosis (evidence before fix)
+
+The q2-preview splice path is `apply_capture_splice` / `derive_cell_outputs` / `is_cell_wrapper`
+(`crates/quarto-core/src/engine/capture_splice.rs`) — NOT `ReplayEngine`, and there is NO staleness
+check. The splice maps each engine cell to the next `::: {.cell}` wrapper in the executed markdown.
+**The brief's PRIMARY candidate (canonical `input_qmd` staleness rejection) is RULED OUT.**
+
+Root cause of the echo PC5 failure (native, deterministic): the echo fixture emitted a bare
+`**ECHO_EXECUTED**` paragraph — no `.cell` wrapper — so `derive_cell_outputs` built an empty map and
+the cell survived as raw source. Julia (decisive native leg, below) wraps its output in
+`::: {#cell-1 .cell execution_count=1}` and splices cleanly. So the splice is CORRECT for real
+engines; echo was an unrealistic fixture. **"Bug B" as a distinct browser/splice defect is
+REFUTED** — the user's live julia symptom re-attributes to Bug A (close/busy discards the capture)
+and/or Bug C (wire corruption + host-kill), both owned by P1/P1c.
+
+### Julia native leg (decisive) — recorded transcript
+
+Isolated fresh server (temp HOME + `IsolatedJuliaServerGuard`, real depot/project/bindir), doc
+`engine: julia / execute: {daemon: false}` with `1 + 1`. `record_capture` →
+
+```
+result.markdown:
+::: {#cell-1 .cell execution_count=1}
+``` {.julia .cell-code}
+1 + 1
+```
+::: {.cell-output .cell-output-display execution_count=1}
+```
+2
+```
+:::
+:::
+
+apply_capture_splice(A2=parse(input_qmd), A1, B1, "julia"): cell_survived=false ← splice fired
+```
+
+## Fix (ratified: fix the FIXTURE, not the splice — splice generalization REJECTED)
+
+`crates/quarto-core/tests/fixtures/extensions/echo-engine/src/echo-engine.ts` — `execute()` now wraps
+the executed output in `::: {.cell}` / `.cell-output` (the shape real engines emit via the
+engine-host's `mdFromCodeCell`). Rebundled the committed `dist/echo-engine.js` via
+`cargo run --bin q2 -- build-ts-extension …`.
+
+**Blast-radius survey (every echo-fixture consumer):** all assertions are substring checks
+(`ECHO_EXECUTED`, `not run by echo`, `{python}`) that survive wrapping. **Zero assertion edits
+required.** `echo_engine_e2e.rs` 9/9 pass; full `cargo nextest run -p quarto-core -p quarto-preview`
+= 2720 passed / 35 skipped / 0 failed.
+
+## PC-B native seam (registered, GREEN) — `capture_splice_seam.rs`
+
+| ID | Tier | Real unit | Seam → assertion | Mock boundary | Revert hunk → RED |
+|----|------|-----------|------------------|---------------|-------------------|
+| PC-B | int-rs (+ deno leg) | `apply_capture_splice` / `is_cell_wrapper` | (1) `.cell`-wrapped capture → source cell REPLACED + output present; (2) bare-paragraph capture → documented NO-OP (cell survives); (3, deno) REAL echo capture → cell replaced + `ECHO_EXECUTED` present | none (real splice; real `record_capture` for leg 3) | (a) `is_cell_wrapper` stops recognizing `.cell` → (1)+(3) RED; (b) revert the echo FIXTURE wrapper → empty map → cell survives → (3) RED (this is the controller-rebound hunk) |
+
+TDD RED→GREEN (leg 3): pre-fix RED — `result.markdown` = bare `**ECHO_EXECUTED**`, `cell_survived=true`,
+panic "the real echo capture must splice … result.markdown:\n**ECHO_EXECUTED**". Post-fix GREEN — 3/3 pass.
+
+## PC5 e2e (chromium) — amended assertion, GREEN, fail-on-revert proven
+
+Controller-amended (option 1): dropped the inert-source-first sub-assertion (unsatisfiable — the
+eager capture is recorded at server startup before the browser connects, so the first render already
+splices; renderTicks=1, no inert frame). Binding assertions now: (a) `ECHO_EXECUTED` appears in the
+pane without reload; (b) the raw source token `PC5_ECHO_SOURCE_TOKEN` is ABSENT (splice REPLACED the
+cell). The spec header documents why inert-first is unsatisfiable and why (a) is non-vacuous. Only
+`test.fail()` and the inert-first block were changed.
+
+**Fail-on-revert proof (mandatory, per decision #2), revert target = `capture_driver.rs` `set_capture`:**
+```
+GREEN (baseline): PC5 passes (2.7s)
+RED (set_capture neutralized): PC5 fails — 30.1s timeout waiting for ECHO_EXECUTED
+GREEN (restored + rebuilt): PC5 passes (2.7s)
+```
+`cargo build --bin q2` between each (native-only; no WASM chain — server-side change). `capture_driver.rs`
+restored clean (empty `git diff`).
+
+## PC7 (jsdom, `PreviewApp.integration.test.tsx`) — GREEN, fail-on-revert proven
+
+New test: after the initial capture-less render, firing `onCapturesChange` with a `CaptureRef` must
+re-fire the render effect (a SECOND `renderPageForPreview` call) and forward the binary-doc bytes.
+
+**Fail-on-revert (two reverts — a finding):**
+```
+Revert A — remove the contentTick bump (PreviewApp.tsx ~:737): GREEN (still passes)
+Revert B — remove the `captures` write (~:733): RED ("getBinaryDocById expected to be called with ['pc7-capture-doc']")
+Restore: GREEN
+```
+**Finding:** the `contentTick` bump inside `onCapturesChange` is REDUNDANT with the render effect's
+`state.captures` dependency (`PreviewApp.tsx:1128`) — a new `captures` reference already re-fires the
+effect. So the controller's intended PC7 revert target (the contentTick bump) does NOT bind; the
+load-bearing hunk is the `captures` write. PC7 binds THAT (revert B → RED), and the test + its
+comment document the redundancy. `PreviewApp.tsx` restored clean.
+
+## PC6 (julia browser leg) — PASSES, but DEFERRED opt-in (concern)
+
+New spec `engine-capture-splice-julia.spec.ts`. It PASSES — a green run is on record: the julia
+`{1+1}` cell's `.cell`-wrapped `2` splices into the pane without reload, 6.5s. It is gated behind
+`QUARTO_PC6_LIVE=1` (skips in the default suite) for one reason:
+
+**The julia transport file is NOT isolated by a temp HOME.** Empirically every julia server (mine
+and the environment's) uses the transport under `QUARTO_JULIA_PROJECT` (the shared instantiated
+project), not under `$HOME/Library/Caches`. So a temp HOME does not yield a fresh isolated server —
+the render reuses the developer's shared julia server/transport. That both violates the isolation
+rule and exposes the run to Bug A (stale busy worker) in CI. Until Bug A is fixed (P1) or the
+transport is truly isolated (an isolated COPY of the instantiated project as `QUARTO_JULIA_PROJECT`),
+PC6 stays opt-in. The unconditional julia proof is the native leg above. This matches the
+controller's "defer if it flakes on A/C; note green run pending P1" latitude.
+
+Added an additive `extraEnv?` option to `e2e/helpers/previewServer.ts` (no existing caller affected)
+so the spec can inject the julia env.
+
+**Server-leak concern:** because the isolation was ineffective, my julia runs (native leg + the
+nextest julia_engine_e2e tests that ran during verification + PC6) left ~4 orphaned QNR servers
+(their temp project dirs are deleted; they serve nothing). I attempted to reap only the ones I
+started (identified by orphaned temp-project path + my-session start times), but the safety
+classifier blocked the `kill`. The user's real server (pid 9828, project `/Users/gordon/docs/julia`)
+was correctly identified and never targeted. **These orphans should be reaped** — e.g.
+`pgrep -f quartonotebookrunner` then kill the ones whose `.jl` path under `/T/.tmp…` no longer
+exists (NOT pid 9828). I did not force this past the safety guard.
+
+## Rebuilds performed before each e2e evidence run
+
+fixture `.ts` → `dist/echo-engine.js` (`build-ts-extension`) → `cargo build --bin q2`. No WASM/SPA
+rebuild: no Rust/WASM product code changed (splice unchanged); the fixture loads server-side at
+runtime (copied fresh into the temp project by each spec); the embedded SPA/WASM (P0's build) is
+current. The set_capture revert used `cargo build --bin q2` only (native server-side change).
+
+## Verification counts (each run once, to a log)
+
+- `cargo nextest run -p quarto-core -p quarto-preview`: **2720 passed, 35 skipped, 0 failed.**
+- PC-B `capture_splice_seam`: 3 passed. `echo_engine_e2e`: 9 passed.
+- q2-preview-spa vitest: unit **25 passed**; integration **76 passed** (incl PC7).
+- q2-preview-spa `npm run test:e2e`: **37 passed, 1 skipped (PC6 opt-in), 1 failed** — the failure is
+ the PRE-EXISTING `firefox-ws-queue` under the **firefox** project (`browserType.launch: Executable
+ doesn't exist … firefox-1522/Nightly.app` — Firefox not installed); the same spec passes under
+ chromium. Orthogonal to this task.
+- PC5 fail-on-revert: GREEN→RED(30.1s)→GREEN. PC7 fail-on-revert: A GREEN (redundant), B RED, restore GREEN.
+
+## Files changed / added (path-scoped)
+
+- `crates/quarto-core/tests/fixtures/extensions/echo-engine/src/echo-engine.ts` (fixture emits `.cell`)
+- `crates/quarto-core/tests/fixtures/extensions/echo-engine/dist/echo-engine.js` (rebundled)
+- `crates/quarto-core/tests/integration/capture_splice_seam.rs` (new PC-B seam) + `main.rs` (register)
+- `q2-preview-spa/e2e/engine-capture-splice.spec.ts` (PC5 amended)
+- `q2-preview-spa/e2e/engine-capture-splice-julia.spec.ts` (new PC6, opt-in)
+- `q2-preview-spa/e2e/helpers/previewServer.ts` (additive `extraEnv`)
+- `q2-preview-spa/src/PreviewApp.integration.test.tsx` (PC7)
+- `capture_driver.rs` and `PreviewApp.tsx` touched only for fail-on-revert proofs; restored clean.
+- throwaway `pcb_diag.rs` deleted.
+
+## Review response (2026-07-02) — commit 2
+
+Two review items addressed (comment-only, no behavior change):
+
+- **Important #1 (stale PC5 header):** rewrote `engine-capture-splice.spec.ts:1-29`. It no longer
+ describes a "WASM render_page_for_preview ReplayEngine splice" (refuted); the chain now ends at
+ the q2-preview pipeline's CaptureSplice stage (`capture_splice.rs`), explicitly notes there is no
+ ReplayEngine and no staleness check, describes the `.cell`-wrapper the echo fixture now emits, and
+ replaces the P0-era "written pre-fix / ratifies before un-skipped" status with the P2 reality
+ (fixture fixed, amended assertion ratified, set_capture fail-on-revert proven).
+
+- **Minor #2 (PC-B comment):** chose the honest option — actually ran the `is_cell_wrapper` revert
+ and recorded it, so the comment's claim is now transcript-validated (no softening needed):
+ ```
+ is_cell_wrapper neutralized (return false):
+ bare_paragraph_capture_is_a_documented_noop PASS (cell survives, expected)
+ cell_wrapped_capture_splices FAIL (leg 1 — cell survives, no .cell matched)
+ real_echo_capture_splices FAIL (leg 3)
+ restored: 3 passed
+ ```
+ Both PC-B revert legs are now proven: (a) `is_cell_wrapper` matching → legs (1)+(3) RED;
+ (b) the echo fixture wrapper emission → leg (3) RED (recorded at fix time). `capture_splice.rs`
+ restored clean.
+
+PC5 re-run after the header edit: GREEN (1.2s).
diff --git a/.superpowers/sdd/task-p3-report.md b/.superpowers/sdd/task-p3-report.md
new file mode 100644
index 000000000..d02abf23f
--- /dev/null
+++ b/.superpowers/sdd/task-p3-report.md
@@ -0,0 +1,258 @@
+# Task P3 report — error-path coverage (PC3, PC8), julia-harness isolation, full verification
+
+**Status:** DONE. This session completed verification only — the substantive TDD and
+isolation work below was done by a previous session (killed mid-verification by a
+session pause) and is reported here from `git show`, not re-derived. Where the report
+says "predecessor's evidence," that RED/GREEN transcript was not independently
+reproduced by this session.
+
+## 1. What the predecessor's commits contain
+
+### c16200f92 — `test(preview-capture): PC3/PC8 error-path coverage (bd-h4rhohhy)`
+
+```
+crates/quarto-preview/src/capture_driver.rs | 127 ++++++++++++++++++++++++++++
+crates/quarto-preview/src/re_execute.rs | 121 ++++++++++++++++++++++++++
+2 files changed, 248 insertions(+)
+```
+
+**PC3** (`capture_driver.rs`, `pc3_failing_engine_does_not_block_next_doc_capture`):
+a `FailingTestEngine` (declares `test-failing`, `execute()` always returns
+`ExecutionError::Other(...)`) registered alongside the existing
+`PassthroughTestEngine`. Two docs, `a-failing.qmd` (engine `test-failing`) and
+`b-echo.qmd` (engine `test-passthrough`) — the `a-`/`b-` prefix matters because
+`qmd_files` is sorted (`discovery.rs:154`), guaranteeing the failing doc runs first,
+so a loop-return-on-Err regression can't hide behind ordering. Assertions: doc A's
+failure emits `Q-PREVIEW-CAP-1` to the test diagnostic sink, AND doc B's capture is
+still recorded (`record_eager_captures`' continue-on-error contract,
+`capture_driver.rs:116-140`). No product-code change — both behaviors already
+existed; this closes an untested gap.
+
+Fail-on-revert proven both ways (per commit message, not re-run by this session):
+(a) commenting out the `sink.emit(...)` call reddens the diagnostic-count assertion;
+(b) replacing the loop's `Err(e) => { .. }` arm with an early `return Err(..)`
+reddens doc B's capture assertion (loop stops at the first failure).
+
+**PC8** (`re_execute.rs`, `pc8_re_execute_failure_sets_error_state_and_emits_diagnostic`):
+a `FailingReExecuteEngine` sharing the doc's declared engine name
+(`test-passthrough`) but always failing, installed via a **wholesale registry
+override** applied only at re-execute time (the seed run used the real
+`PassthroughTestEngine` and is unaffected). Uses a **separate cache dir** from the
+seed run — required because `record_capture_cached` keys purely on
+`sha256(input_qmd)` (`cache.rs:150-163`), and content is unchanged between seed and
+re-execute, so reusing the seed's cache dir would replay the cached success and
+never invoke the failing engine. Assertions: `perform_re_execute`'s failure branch
+(`re_execute.rs:253-279`) writes sidecar `CaptureState::Error` + `last_error`, and
+emits `Q-PREVIEW-RE-1`. No product-code change.
+
+Fail-on-revert (per commit message): commenting out the
+`ctx_for_task.index().set_capture(&rel_path_for_task, &errored)` write leaves the
+sidecar stuck at whatever state `claim_and_spawn` set pre-run, reddening the
+`CaptureState::Error`/`last_error` assertions.
+
+### 4785c9d2c — `test(preview-capture): isolate QUARTO_JULIA_PROJECT in live-julia harnesses (bd-h4rhohhy P3)`
+
+```
+.../plans/2026-07-02-preview-capture-delivery.md | 54 ++++++++-
+.../tests/integration/julia_engine_e2e.rs | 129 ++++++++++++++++++++-
+.../e2e/engine-capture-splice-julia.spec.ts | 77 +++++++++---
+3 files changed, 236 insertions(+), 24 deletions(-)
+```
+
+PC4a and PC6 already isolated the julia transport/server via a temp `HOME`. This
+commit adds a **second isolation layer**: `isolate_julia_project()` (Rust,
+`julia_engine_e2e.rs:124-138`) / `isolateJuliaProject()` (TS,
+`engine-capture-splice-julia.spec.ts`) copy the ambient `QUARTO_JULIA_PROJECT`'s
+`Project.toml` + `Manifest.toml` into a per-test temp dir and re-point
+`QUARTO_JULIA_PROJECT` at the copy, so the detached server's `--project=` flag
+never names the shared real directory (`JULIA_DEPOT_PATH` stays shared — no
+package re-instantiation). Both harnesses gained a
+`SharedTransportSentinel`/`captureSharedTransportMtime()` assertion that the
+shared `~/Library/Caches/quarto/julia/julia_transport.txt` existence+mtime is
+unchanged across the run.
+
+**Live-verified twice by the predecessor** (evidence from the commit message,
+not re-run by this session): `pc4a_abandoned_worker_close_busy` (78.8s,
+`QUARTO_PC4A_LIVE=1 ... --run-ignored all`) and PC6
+(`QUARTO_PC6_LIVE=1 npx playwright test engine-capture-splice-julia`, 8.6s) —
+both PASSED; shared transport file mtime/existence unchanged both times;
+`IsolatedJuliaServerGuard` reaped every process it spawned (no new leaked pids).
+Found but explicitly did NOT touch ~28 pre-existing leaked julia processes on the
+shared transport (pre-existing `bd-l9jhy5u0` leak, out of scope, "never touch
+processes you didn't start" constraint honored).
+
+Decision recorded: `QUARTO_PC6_LIVE` stays **opt-in** — isolation is no longer the
+reason (proven safe); the remaining reason is environmental/speed (real
+julia+deno dependency, multi-second server boot), mirroring PC4a's `#[ignore]`
+gate. Documented in the spec's file header and (now, after this session's fix
+below) the plan's seam table.
+
+## 2. Dangling edit disposition (this session)
+
+One uncommitted edit was found on disk in
+`crates/quarto-core/tests/integration/julia_engine_e2e.rs`: a clippy-shape
+rewrite of PC4a's markdown extraction, `.map(|c| ...).unwrap_or_else(|| panic!(...))`
+→ `.map_or_else(|| panic!(...), |c| ...)`. Verified semantically identical (same
+panic-on-missing-capture, same markdown extraction on hit) and confirmed it
+compiles clean (`cargo check -p quarto-core --tests`). Committed as
+`f0728dd63` — `style(quarto-core): use map_or_else in PC4a markdown extraction
+(bd-h4rhohhy)`.
+
+## 3. Verification ladder (this session)
+
+All logs in `/tmp/bd-h4rhohhy-p3-logs/` (not committed — scratch).
+
+### Leg A — `cargo nextest run -p quarto-preview -p quarto-core`
+
+**Result: 2723 tests run: 2721 passed, 2 failed, 35 skipped.**
+
+Log: `/tmp/bd-h4rhohhy-p3-logs/leg-a-nextest.log`.
+
+Two failures, both in `julia_engine_e2e.rs`, **neither part of this branch's
+PC3/PC8/PC6 work** (pre-existing tests from the original julia-validation plan):
+
+- `julia_engine_e2e::j1_minimal_julia_render`
+- `julia_engine_e2e::j2_document_level_echo_false_hides_source_keeps_output`
+
+Both failed with `Julia server returned error after receiving "isopen" command:
+Incorrect HMAC digest` — a shared-julia-transport handshake failure. These
+tests use `setup_julia_project()` (NOT the isolated
+`IsolatedJuliaServerGuard`/`isolate_julia_project()` path — that's PC4a-only),
+so they connect to the AMBIENT `~/Library/Caches/quarto/julia/julia_transport.txt`.
+At the time of the run the machine also had **~49 leaked julia server
+processes** from unrelated test activity (the pre-existing `bd-l9jhy5u0`
+worker-leak bug), which was the initial suspect.
+
+**Confirmed transient, not a regression**: re-ran each failing test in isolation
+(single test, no concurrent julia contention):
+
+```
+cargo nextest run -p quarto-core --test integration -- julia_engine_e2e::j1_minimal_julia_render
+ → PASS (6.1s)
+cargo nextest run -p quarto-core --test integration -- julia_engine_e2e::j2_document_level_echo_false_hides_source_keeps_output
+ → PASS (5.9s)
+```
+
+Both pass cleanly in isolation. **The full diagnosis came during Leg C** (see
+below): the orphan pool was only an amplifier — the real root cause is that the
+`julia_engine_e2e` tests race EACH OTHER on the single ambient transport file.
+Fixed by nextest serialization (commit `156b290ec`); see Leg C.
+
+### Leg B — `cargo build --bin q2` + q2-preview-spa `npm run test:e2e`
+
+`cargo build --bin q2`: succeeded (log: `leg-b-build.log`).
+
+`npm run test:e2e` (log: `leg-b-e2e.log`): **37 passed, 1 skipped, 1 failed**
+(39 total across the `chromium` + `firefox-ws-queue` projects).
+
+- Skipped: `[chromium] engine-capture-splice-julia.spec.ts` PC6 — expected,
+ opt-in (`QUARTO_PC6_LIVE` unset).
+- Passed: PC5 (`engine-capture-splice.spec.ts`) — `PC5: recorded echo capture
+ splices into the pane without reload`.
+- Failed: `[firefox-ws-queue] firefox-ws-queue.spec.ts` —
+ `browserType.launch: Executable doesn't exist at
+ .../ms-playwright/firefox-1522/firefox/Nightly.app/...` — Firefox not
+ installed on this machine. This is the **known pre-existing failure** the
+ brief called out; it is the ONLY e2e failure, matching the expected shape.
+
+### Leg C — full `cargo xtask verify` (three runs; the whole story)
+
+**Run 1** (log: `leg-c-xtask-verify.log`, dirty machine — ~49 orphaned QNR
+processes present): FAILED at the Rust-tests step.
+`Summary [78.4s] 8624/10589 tests run: 8623 passed, 1 failed, 198 skipped`
+(nextest fail-fast cancelled the rest). Failure:
+`julia_engine_e2e::j1_minimal_julia_render`, `Incorrect HMAC digest` at
+`isopen`. Initial hypothesis: contention from the orphaned QNR pool
+(bd-l9jhy5u0).
+
+**Orphan-pool cleanup** (user-approved, done by the coordinator between runs):
+~50 orphaned QuartoNotebookRunner processes were reaped via category-pattern
+pkill; the user's own julia server (pid 9828) was preserved. Machine clean.
+
+**Run 2** (log: `leg-c-xtask-verify-2.log`, clean machine): FAILED AGAIN.
+`Summary [74.6s] 8758/10589 tests run: 8756 passed, 2 failed, 198 skipped`.
+Failures: `julia_engine_e2e::j3_exeflags_and_env_through_julia_block` and
+`julia_engine_e2e::j4_error_handling_does_not_wedge_host` — same
+`Incorrect HMAC digest` signature, but DIFFERENT victims than run 1.
+
+**Diagnosis — intra-suite race, structural, pre-existing.** Rotating victims
+on a clean machine rule out the orphan pool as the root cause. The
+`julia_engine_e2e` tests had NO nextest serialization; each uses
+`setup_julia_project()`, which isolates the project dir but NOT `HOME`, so
+every concurrently-running j-test's `daemon: false` render boots its own QNR
+server against the SINGLE ambient
+`~/Library/Caches/quarto/julia/julia_transport.txt` — concurrent startups
+overwrite each other's transport entry (port/pid/HMAC key), and a client that
+reads the wrong entry fails the socket handshake with `Incorrect HMAC digest`.
+Which test loses the race depends on scheduling — hence rotating victims.
+This is a **pre-existing test-infra property, not caused by this branch's
+diff**: the j-tests, `setup_julia_project()`, and the shared-transport reuse
+design all predate P3 (they belong to the julia-validation plan), and this
+branch's P3 commits touch only new PC3/PC8 tests, the PC4a/PC6 isolation
+helpers (which are NOT used by j1-j6), and a comment-shape edit. We did NOT
+re-verify on the merge-base — a merge-base run would race the same way only
+probabilistically (the failures are scheduling-dependent, so a green
+merge-base run would prove nothing and a red one would only confirm what the
+structural argument already establishes: none of the racing components
+changed on this branch).
+
+**Fix** (commit `156b290ec`, config-only, precedent bd-u3ze): added a
+`julia-shared-transport = { max-threads = 1 }` test-group in
+`.config/nextest.toml` with an override filtering
+`package(quarto-core) & binary(integration) & test(/^julia_engine_e2e::/)`,
+so at most one j-test is in flight at a time. The j-tests themselves are
+untouched (out of this task's scope; full hermetic isolation for them is
+being filed as a separate strand by the controller).
+
+**Run 3** (log: `leg-c-xtask-verify-3.log`, clean machine + serialization):
+
+**Run 3 (post-serialization, log: `leg-c-xtask-verify-3.log`): ALL GREEN.**
+Tail: `✓ All verification steps passed!`; nextest summary:
+`10589 tests run: 10589 passed, 198 skipped` (0 failed — the julia_engine_e2e
+tests now run serialized in the `julia-shared-transport` group). All downstream
+legs (ts-packages builds + mcp smoke, hub-client `build:all` incl. WASM,
+hub-client `test:ci`) completed successfully.
+*(Filled in by the controller from the run-3 log after the implementer session
+ended; independently re-verified by the P3 reviewer.)*
+
+## 4. PC6 gate decision
+
+**Decision: PC6 stays opt-in (`QUARTO_PC6_LIVE=1`), per the predecessor's
+already-recorded rationale — confirmed correct by this session, plan updated to
+match.**
+
+Read `isolate_julia_project()` (`julia_engine_e2e.rs:124-138`) and
+`isolateJuliaProject()` (`engine-capture-splice-julia.spec.ts`), and the spec's
+file-header comment. The isolation code is real (copies `Project.toml`/
+`Manifest.toml` into a per-test temp dir, re-points `QUARTO_JULIA_PROJECT`,
+layered on top of the pre-existing temp-`HOME` transport override) and its
+safety claim is backed by the predecessor's two live runs (evidence above) — not
+independently re-run by this session (re-running would spawn another real julia
+server on an already-contaminated machine — 49 leaked processes present — for no
+new information; the predecessor's evidence already satisfies "prove it").
+
+Per the brief: "drop the `QUARTO_PC6_LIVE` opt-in gate IF the isolation makes it
+safe for the julia-gated tier, else record explicitly why it stays opt-in." The
+isolation DOES make PC6 safe from a shared-state-corruption standpoint — but
+"safe" and "fast/deterministic enough for the default CI suite" are different
+questions. PC6 spawns a real julia server (network-installed julia binary,
+multi-second boot, ~6.5-8.6s per the recorded runs) — the same class of cost
+that keeps PC4a behind `#[ignore]` on the Rust side. The gate stays for that
+reason, not for isolation safety.
+
+**Plan/code disagreement found and fixed**: the plan's seam table PC6 row was
+STALE — it still read "Opt-in because a temp HOME does NOT isolate the shared
+julia transport... un-deferral tracked in P3" (the PRE-P3 rationale), even
+though the P3 checklist item immediately above it (already correct, predecessor's
+text) already recorded the isolation-closed / speed-is-the-reason-now decision.
+Fixed in this session: the PC6 seam-table row now states the post-P3 rationale
+explicitly and points to this report. (Commit: bundled with the plan-checklist
+reconciliation below.)
+
+## 5. TDD evidence provenance
+
+**RED/GREEN evidence in §1 above (PC3, PC8, PC4a/PC6 live runs) is the
+predecessor's** — read from commit messages and code comments, not independently
+re-derived or re-run by this session. This session's own verification work is:
+the dangling-edit compile check (§2), the full ladder (§3), and the isolated
+re-runs of `j1`/`j2` that confirmed those two failures are transient (§3, Leg A).
diff --git a/Cargo.lock b/Cargo.lock
index 32c10524e..cd5682f1f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3664,12 +3664,14 @@ dependencies = [
"serde_yaml",
"sha2 0.11.0",
"smallvec",
+ "socket2",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"tokio-util",
"tracing",
+ "tracing-subscriber",
"uuid",
"walkdir",
"which",
@@ -4115,6 +4117,7 @@ dependencies = [
name = "quarto-util"
version = "0.10.0"
dependencies = [
+ "dirs",
"serde",
"thiserror 2.0.18",
]
diff --git a/claude-notes/designs/2026-07-24-preview-capture-splice-three-way-merge.md b/claude-notes/designs/2026-07-24-preview-capture-splice-three-way-merge.md
new file mode 100644
index 000000000..bfba5dbae
--- /dev/null
+++ b/claude-notes/designs/2026-07-24-preview-capture-splice-three-way-merge.md
@@ -0,0 +1,254 @@
+# Preview capture-splice as a three-way merge
+
+**Status:** Proposed — **deferred**. Do not implement from this document yet; it
+was judged too risky to undertake at the time of writing. The purpose here is to
+preserve the design and its reasoning so that a future implementer begins from
+the analysis rather than rediscovering it.
+
+**Date:** 2026-07-24
+
+**Related code**
+- `crates/quarto-core/src/engine/capture_splice.rs` — the current lock-step walk this design would replace.
+- `crates/quarto-ast-reconcile/` — the two-way AST reconciliation this design builds on.
+- `crates/quarto-core/src/stage/stages/engine_execution.rs` — the render path, which already uses two-way reconciliation (`reconcile(ast, executed_ast)`).
+
+**Related strands:** bd-lucp (original splice), bd-7hqea3qi (Div recursion),
+bd-5jxcio5d (marimo RawBlock), bd-5oyk1xce (Bug B, multi-engine fold),
+bd-5m1ni9if (open splice edge).
+
+---
+
+## 1. Why preview needs a splice at all
+
+`q2 preview` shows the reader executed engine output — plots, computed tables,
+marimo widgets — while they edit the source. Re-running the engine on every
+keystroke is not viable, so the preview runs the engine **once**, server-side,
+and records the result as an `EngineCapture`: the triple
+`(engine_name, input_qmd, result_markdown)`. Every later edit reuses that one
+capture.
+
+Reuse is the whole problem. The moment the reader types a character, the live
+source no longer matches the source that was captured, so the recorded output
+cannot simply be replayed verbatim. The splice exists to bridge that gap: it
+takes the recorded transformation and re-applies it to the edited document.
+
+Three ASTs frame the task. Naming them once here fixes the vocabulary for the
+rest of the document:
+
+- **A1** — the captured *pre-engine* AST (`parse(capture.input_qmd)`).
+- **B1** — the captured *post-engine* AST (`parse(capture.result_markdown)`).
+- **A2** — the *live, edited* pre-engine AST (what the current source produces
+ before the engine would run).
+
+The engine turned A1 into B1. The splice must produce **B2** — what the engine
+*would* produce for A2 — without running the engine again.
+
+## 2. The current algorithm and its recurring failure
+
+The current splice (`derive_cell_outputs_walk` in `capture_splice.rs`) treats the
+capture as a recipe in two steps. First it diffs A1 against B1 to learn "which
+output block did the engine emit for each source cell," recording a map keyed by
+`(structural_hash(cell), occurrence_index)`. Then it walks A2 and, for each
+source cell, swaps in that cell's recorded output.
+
+The diff in the first step is a hand-rolled **lock-step walk**. It advances two
+pointers through A1 and B1 in parallel under three assumptions: prose blocks
+appear identically in both and advance both pointers; each engine cell in A1 maps
+to exactly **one** "engine-output block" in B1 (a `.cell` wrapper Div, or a
+marimo island `RawBlock`); and any divergence from this pattern stops the walk
+(the *fail-soft* rule — whatever was collected before the divergence stays valid,
+and everything after falls through to raw source).
+
+That "exactly one output block per cell" assumption is the recurring fault line.
+Real engines violate it in a new way every few months, and each violation has
+arrived as a silent, output-dropping bug with no error anywhere:
+
+- **bd-7hqea3qi** — a figure-labelled cell is *nested* in a float Div, so the
+ walk hit two unequal Divs and stopped. Fix: recurse into Div content.
+- **bd-5jxcio5d** — marimo emits islands as bare `RawBlock`s, not `.cell` Divs,
+ so nothing matched. Fix: widen the output-block predicate to accept `RawBlock`.
+- **bd-5oyk1xce (Bug B)** — a foreign engine's un-executed cell stalled the B1
+ pointer and derailed the walk. Fix: advance past a structurally-equal
+ passthrough block.
+- **The bug that prompted this document** — an `echo: true` marimo cell emits
+ **two** sibling blocks (an echoed-source `CodeBlock`, then the island). The
+ echoed `CodeBlock` is not an output block, so the walk breaks at the first such
+ cell and drops every cell after it.
+
+Each fix is correct and well-tested. Together they are a symptom: the model
+underlying the walk is weaker than the output real engines produce, so the model
+accretes special cases instead of generalizing.
+
+## 3. The insight: render already reconciles; preview is the three-way case
+
+The render pipeline does not use this walk. After the engine runs, render calls
+`quarto_ast_reconcile::reconcile(ast, executed_ast)` — a general, content-hash
+two-way reconciliation — to merge the pre-engine and post-engine ASTs while
+preserving source locations. That path renders `index.qmd`'s six marimo islands
+correctly. The preview path, using the bespoke walk on the *same* capture, drops
+five of them. The reconciliation machinery is the part that works; the bespoke
+walk is the anomaly.
+
+The reason preview forked away from reconciliation is real, not accidental.
+`reconcile` is **two-way**: it merges one before-AST with one after-AST for the
+*same* document version. Preview is **three-way**: it must combine the recorded
+transformation (A1→B1) with a *different*, edited document (A2). A two-way merge
+does not directly express that.
+
+The design in this document closes the gap by building the three-way merge **on
+top of** the two-way primitive, rather than hand-rolling a diff beneath it.
+
+## 4. The design: a three-way merge over blocks
+
+A three-way merge (the classic `diff3`) needs two diffs against a shared base.
+Here the base is A1, and the two-way reconciliation supplies both diffs:
+
+- the **engine diff**, `reconcile(A1, B1)` — which B1 blocks are unchanged prose
+ and which are new engine output;
+- the **user diff**, `reconcile(A1, A2)` — which A2 blocks the reader left
+ untouched and which they edited.
+
+The two-way primitive reports each alignment as `KeepBefore` (content-identical
+to a base block — a hash match), `UseAfter` (new or changed content), or
+`RecurseIntoContainer` (same container, descend). From those two alignments the
+merge finds the base blocks matched in *both* diffs — the stable anchors — and
+classifies each chunk between consecutive anchors by how its A2 (the reader's)
+and B1 (the engine's) ranges relate to the base:
+
+| A2 vs. base | B1 vs. base | result |
+|---|---|---|
+| unchanged | changed | take **B1** (splice the engine output) |
+| changed | unchanged | take **A2** (edited cell falls through to raw source) |
+| unchanged | unchanged | keep the base |
+| changed | changed | conflict → take **A2** (raw source) |
+
+This table earns its keep by *deriving* today's behavior instead of hand-coding
+it. An unedited cell shows engine output; a cell the reader is actively editing
+shows raw source until the next capture — exactly the current contract, now a
+consequence of the merge rather than a special case inside a walk.
+
+## 5. Cell-order attribution: the one piece the merge does not give for free
+
+Block-level `diff3` handles a single expanded cell cleanly. For an `echo: true`
+cell the base chunk is `[cell]`, the reader's chunk is the unchanged `[cell]`,
+and the engine's chunk is `[echoCode, island]`; the table says "take the
+engine's chunk," and both blocks splice with no per-shape knowledge required. The
+entire family of output-block predicates disappears.
+
+Adjacent cells with a partial edit are the case the merge cannot resolve on its
+own, and the reason is fundamental: **the engine erases cell identity.** A cell
+and the island it becomes share no content, so a content diff finds *no anchor*
+inside a run of adjacent cells. Consider `index.qmd`'s first two cells, which sit
+together with no prose between them, when the reader edits only the second:
+
+```
+base A1 = [ cell1, cell2 ]
+live A2 = [ cell1, cell2' ] (only cell2 edited)
+engine B1 = [ island1, echoCode2, island2 ]
+```
+
+The base↔engine diff anchors nothing here — `cell1` and `cell2` both vanished
+into unrelated output — so `diff3` sees one chunk changed on both sides,
+declares a conflict, and takes the reader's side: `[cell1, cell2']`. That drops
+`cell1`'s island even though the reader never touched `cell1`. The result is
+worse than the bug being fixed.
+
+The resolution keeps the merge but adds one assumption that holds for every
+execution engine: **the engine emits output in cell order and touches only
+cells.** Within an engine-changed region, attribute the output run to source
+cells by order — cell *k* owns the run up to where cell *k+1*'s output begins —
+producing a per-cell output run. The "did the reader edit this cell" test then
+runs per cell, on the hash of the cell's source, which is precisely today's
+`(hash, occurrence)` key. In the example, `cell1` (unedited) takes `island1`,
+`cell2` (edited, hash miss) falls through to `cell2'`, and `echoCode2`/`island2`
+are attributed to `cell2` and dropped as stale — yielding `[island1, cell2']`,
+which is correct.
+
+The attribution is the one place engine-specific reasoning can re-enter, because
+splitting a multi-block run across adjacent cells needs a *cell-boundary* signal
+(see the open questions). The design's aim is to confine that reasoning to a
+single, explicit place instead of spreading it across a growing predicate.
+
+## 6. Why this is more general and less fiddly
+
+The current walk carries a table of shapes it must recognize — `is_cell_wrapper`,
+`is_engine_output_block`, the `RawBlock` special case, the passthrough rule — and
+every new engine output shape adds a row. The three-way merge removes that table.
+It rests instead on one assumption that is true of every engine we support: prose
+passes through untouched, and cell outputs are emitted in cell order. The remaining
+hard case — adjacent cells under a partial edit — is handled once, by order
+attribution, rather than re-litigated per engine.
+
+## 7. Open design questions
+
+The merge trades a growing list of per-shape patches for a small set of sharper
+questions. These are unresolved and would need answers before implementation:
+
+1. **Cell-boundary delimitation.** Order tells us cell *k* precedes cell *k+1*,
+ but not where a multi-block run splits between them. The available signals
+ (`.cell` Div per cell, one island per cell) are engine-specific — the very
+ knowledge the merge set out to remove. The honest question is whether we can
+ avoid a per-engine boundary abstraction, or should instead shrink the
+ engine-specific surface to one declared contract ("emit one recognizable
+ boundary per cell").
+
+2. **Fall-through granularity: per cell or per region.** If we decline to solve
+ (1), the simple alternative reverts an entire adjacent-cell region to raw
+ source when any cell in it is edited. Coarser, fully engine-agnostic, and
+ possibly fine for a preview that re-captures on save. This is a product
+ decision, not a technical one.
+
+3. **Order attribution vs. content keying under reordering.** Cells match by
+ position-independent `(hash, occurrence)`, but output attribution is
+ order-based. Reordering two unedited cells can make the two disagree. The
+ design needs one coherent rule.
+
+4. **Is prose actually invariant?** `results='asis'`, inline execution, and
+ markdown-emitting cells (`mo.md()`) rewrite prose, shrinking the anchor set.
+ The design needs an explicit definition of "anchor" (likely: only
+ hash-identical-across-A1↔B1 blocks) and a stated behavior for these engines.
+
+5. **Three-way recursion into containers.** A `::: {#fig-…}` float wraps the raw
+ cell in A1 and the executed output in B1 — same container, changed content.
+ The merge needs a rule for when to descend and merge children versus treat the
+ whole container as one changed chunk, keeping the occurrence counter in
+ document order across nesting levels.
+
+6. **Multi-engine fold.** Captures fold in sequence; engine 2's base is engine
+ 1's spliced output. Whether sequential three-way merges compose correctly, or
+ interleaved output needs a joint attribution, must be first-class — Bug B lived
+ in exactly this seam.
+
+7. **Fail-soft floor.** Today's walk cannot emit wrong output; its worst case is
+ raw source. A merge can be confidently wrong — stale output spliced onto a
+ reverted cell, or mis-attributed across adjacent cells. Stale-but-plausible
+ output is arguably worse than visibly-raw source, so the design should keep a
+ guard: splice only when attribution is unambiguous, else fall through.
+
+8. **Cost and caching.** The merge runs in WASM on roughly every edit and costs
+ two reconciliations plus classification, against today's single O(n) walk. The
+ engine diff (A1→B1) is fixed per capture; only the user diff (A1→A2) changes
+ per keystroke, so the engine side should be cached per capture from the start.
+
+9. **The reframing question — attribute at capture time, not splice time.** Every
+ question above is a consequence of one earlier decision: the capture stores a
+ flat markdown blob, discarding the cell→output correspondence the engine knew
+ exactly at execution time. If the capture instead recorded structured per-cell
+ output (`Vec<(cell_key, output_blocks)>`), the browser splice would collapse to
+ a keyed lookup per A2 cell, and questions 1–7 would not arise. The cost moves
+ into the engine-host capture contract. This is the sharpest fork: a principled
+ inference engine in the browser, versus recording what the engine already knew.
+ It should be settled before the merge is built, because it may make the merge
+ unnecessary.
+
+## 8. Risk and why this is deferred
+
+The current walk, for all its accreted patches, has a strong safety property: it
+never emits wrong output. Replacing it with a merge introduces the possibility of
+confident mis-attribution (question 7), touches a component that runs on every
+keystroke in WASM (question 8), and interacts with the multi-engine fold that has
+already produced one subtle bug (question 6). The reframing question (question 9)
+may also redirect the whole effort toward the capture contract instead. Given
+that the immediate bug has a small, contained fix that stays inside the current
+model (see the companion discussion), the merge is recorded here and deferred
+rather than started now.
diff --git a/claude-notes/designs/document-profile-contract.md b/claude-notes/designs/document-profile-contract.md
index 2447b68f9..034e4c870 100644
--- a/claude-notes/designs/document-profile-contract.md
+++ b/claude-notes/designs/document-profile-contract.md
@@ -2,7 +2,7 @@
**Status:** Active (Phase 0 of the website epic, `bd-0tr6` / `bd-f3jc`;
extended in Phase 8 sub-phase 8.0, `bd-fegm` + `bd-r82e`).
-**Version tag:** `DOCUMENT_PROFILE_VERSION = 2`
+**Version tag:** `DOCUMENT_PROFILE_VERSION = 7`
**Type:** `quarto_core::document_profile::DocumentProfile`
**Stage:** `quarto_core::stage::stages::DocumentProfileStage` (name
`"document-profile"`) + `UnwrapProfileStage` (`"unwrap-profile"`),
@@ -66,14 +66,22 @@ produced.
| `categories_raw` | `Option` carrying the originating tagged value of the top-level `categories:` key (`bd-n8a4`). Mirrors `categories` but preserves `!prefer` / `!concat` merge tags so listings consumers can feed it (alongside `listing_item.categories_raw`) into `quarto_config::MergedConfig` for tag-aware merging. Most consumers should keep reading the flattened `categories`; only listings reach for the raw form. Default `None`. |
| `listing_content_globs` | `Vec` of unresolved glob strings from the host page's `listing.*.contents:` declarations (`bd-xbnf`, listings L6). Flattened across all listings on the page. The dependency-graph builder expands these against `ProjectIndex` at graph-build time (host-relative first, project-relative fallback — matches L3's render-time rule) to add forward edges from each listing host to its content files; hosts with non-empty entries are also added to the graph's `force_render` set so Mode B (`quarto render posts/foo.qmd`) pulls in listing hosts when any of their content files is targeted. Resolution is **not** cached on the profile (the per-doc cache cannot represent dependency on the full project source set safely). Default empty. |
| `listing_item` | `ListingItemInfo` advertising per-document data for listings consumers (`bd-n8a4`). **Scoped feature surface — listings only**; non-listing consumers must use the corresponding top-level fields (`title`, `description`, `image`, …). Author-supplied values populate during `DocumentProfile::extract`; `ListingItemInfoStage` (`bd-izqh`, L1, landed) auto-fills holes pre-checkpoint for `description` (full first paragraph), `image` (first inline image's URL), `word_count` (Q1-parity tokenization, footnote text excluded), `reading_time_minutes` (`ceil(word_count / 200)`), and `date_modified` (filesystem mtime via `SystemRuntime::path_metadata` formatted as `YYYY-MM-DD` UTC). Author values always win — the stage strictly fills holes. The nested `extra: BTreeMap` is the **only** open-shape field in the profile and is forbidden to non-listing consumers — see §"Scoped feature surfaces". Default empty (`ListingItemInfo::is_empty()`). |
+| `engine_resolution` | `Option` (`engine-resolution.md` §9.1). `Some` only when the document's engine resolution is provably load-free at Pass-1 — the needs-no-load predicate in `engine-resolution.md` §3.3 (P1–P4) — and is then **complete**: `sequence` is the resolved engine names in run order, `ownership` is the language→engine map in insertion order. `None` means resolution fell through to Pass-2's existing (non-profiled) resolution — **not an error**; most documents may show `None` until every engine a project uses is static or tabled (`engine-resolution.md` §3.3, §12). Names only, no `ConfigValue` blobs. Default `None`. |
## Non-guarantees (explicit)
What a profile **does not** contain:
-- **Engine output.** No values produced by executing code cells
- (Jupyter, Knitr, Observable). Those require the engine stage,
- which runs after the checkpoint.
+- **Engine execution output.** No values produced by executing code
+ cells (Jupyter, Knitr, Observable). Those require the engine
+ stage, which runs after the checkpoint. **Engine *resolution* is
+ the exception to this line, not a contradiction of it:** deciding
+ which engine(s) will run and which owns which language is a pure,
+ pre-load computation (`engine-resolution.md` §9) and *is*
+ profile-eligible — see the `engine_resolution` field above. The
+ boundary is between *deciding* an owner (resolution, may be on the
+ profile) and *running* that owner to get a value (execution, never
+ on the profile).
- **Sugar-synthesized structure.** No callout custom nodes, no
theorem/float-target/equation-label canonicalization, no
crossref numbering (`TocEntry::number`), no appendix structure,
@@ -415,22 +423,35 @@ Tracking: `bd-creo` (CLI strictness), `bd-mwtf` /
changed from `Vec` to `Vec` so each
pattern carries its YAML `SourceInfo` for Ariadne-span
diagnostics.
-- **2026-07-15 — v7 (`bd-ez0hiowa`, title-block parity epic P2).**
- `DOCUMENT_PROFILE_VERSION` bumped 6 → 7. One new field:
- - `authors_structured: Vec` — the structured
- author model (name literal + given/family components, ORCID,
- email, url, degrees, attribute flags, denormalized
- affiliations as `ProfileAffiliation { name, department,
- url }`). Produced by the shared normalization in
- `crates/quarto-core/src/metadata/authors.rs`
- (`parse_authors_model`) — the same pass
- `AuthorsNormalizeTransform` uses to derive the
- `by-author`/`by-affiliation` metadata the HTML title block
- renders. The flat `authors: Vec` field keeps its type
- and now derives its literals from the same model, so the two
- fields always agree. Fields the profile does not carry yet
+- **2026-07 — v8 (merge of two concurrent v7 bumps).** Two branches
+ each bumped `DOCUMENT_PROFILE_VERSION` 6 → 7 for a different new
+ field; the ts-engine-extensions rebase merged them, so both fields
+ coexist under **v8** (there is no single-field v7 on the merged
+ line). v6/v7 cache entries on disk are rejected with
+ `DocumentProfileError::VersionMismatch` and silently regenerated,
+ identical to every prior bump. Both new fields:
+ - `authors_structured: Vec` (`bd-ez0hiowa`,
+ title-block parity epic P2) — the structured author model (name
+ literal + given/family components, ORCID, email, url, degrees,
+ attribute flags, denormalized affiliations as
+ `ProfileAffiliation { name, department, url }`). Produced by the
+ shared normalization in `crates/quarto-core/src/metadata/authors.rs`
+ (`parse_authors_model`) — the same pass `AuthorsNormalizeTransform`
+ uses to derive the `by-author`/`by-affiliation` metadata the HTML
+ title block renders. The flat `authors: Vec` field keeps
+ its type and now derives its literals from the same model, so the
+ two fields always agree. Fields the profile does not carry yet
(roles, notes, funding) join later with another bump.
- v6 cache entries on disk are rejected with
- `DocumentProfileError::VersionMismatch` and silently
- regenerated, identical to every prior bump.
- Plan: `claude-notes/plans/2026-07-15-html-title-block-parity.md`.
+ Plan: `claude-notes/plans/2026-07-15-html-title-block-parity.md`.
+ - `engine_resolution: Option` (plan6,
+ Pass-1 engine resolution) — the per-document engine resolution,
+ additive at the on-disk layer (`skip_serializing_if` keeps default
+ profiles compact), stamped only when Pass-1 can resolve it without
+ loading an engine (`engine-resolution.md`'s needs-no-load
+ predicate, §3.3/§7/§9.1). `None` means the doc falls through to
+ Pass-2's existing resolution — not an error. Names only, no
+ `ConfigValue` blobs: `sequence` is the resolved engine names in run
+ order, `ownership` is the language→engine map in insertion order.
+ Feeds the LSP today; future freeze and kernel-pooling consumers
+ later (`engine-resolution.md` §12).
+ Plan: `claude-notes/plans/2026-06-29-plan6-pass1-engine-resolution.md`.
diff --git a/claude-notes/designs/engine-and-engines-keys.md b/claude-notes/designs/engine-and-engines-keys.md
new file mode 100644
index 000000000..008362cc6
--- /dev/null
+++ b/claude-notes/designs/engine-and-engines-keys.md
@@ -0,0 +1,212 @@
+# The `engine:` and `engines:` metadata keys (design contract)
+
+**Status:** design contract — authoritative for what each key means and,
+just as importantly, what each key is guaranteed *not* to do. Describes the
+settled behavior delivered by the **TS Engines Epic**; § History records
+how the two keys reached it. Referenced by that epic's implementation
+plans.
+**Created:** 2026-07-06 (during the TS Engines Epic's engine-key design
+work).
+**Companion contract:** `engine-resolution.md` owns the resolution
+algorithm these keys feed (claim kinds, tiers, ownership); this document
+owns the *user-facing grammar* — which key to write, and why.
+
+---
+
+## 1. Why there are two keys
+
+Quarto has always had two engine-related configuration keys, and they have
+always been easy to confuse — one letter apart, both about engines. The
+q2 design resolves the confusion by giving each key exactly one question
+to answer:
+
+- **`engine:`** answers *"which engines are at play in this document?"*
+- **`engines:`** answers *"how do engines behave, whichever ones end up
+ at play?"*
+
+Everything else in this document is a consequence of that division. When
+you are deciding which key to write, ask which question you are answering:
+if you are picking participants, write `engine:`; if you are adjusting the
+behavior of a participant someone else might pick, write `engines:`.
+
+## 2. `engine:` — naming the engines at play
+
+A document's `engine:` key declares its **execution sequence**: the
+engines that will run, in the order they will run. Because the key *names
+participants*, writing it is committal — q2 treats an explicit list as the
+author's complete statement of who plays, and three consequences follow:
+
+1. **The implicit-fallback safety net turns off.** Normally, a
+ computational language that no engine claims falls to whichever
+ registered engine makes the strongest `Fallback` claim — jupyter by
+ default, but any engine may declare fallback claims and outbid it (the
+ "T4" tier in `engine-resolution.md` §4.3). An explicit `engine:` list
+ disables that whole tier: if your list doesn't cover a language, no
+ unlisted engine is quietly added to cover it. (A *listed* engine's
+ fallback claims still catch leftovers — that is the explicit-fallback
+ tier, T2.)
+2. **Listed engines are present by declaration.** An engine's *interop*
+ claims (knitr's reticulate taking `{python}`, for example) fire only
+ when the engine is already present. Listing an engine makes it
+ present, even in a document where it wins no language on its own.
+3. **Order is execution order.** Engines run sequentially, each consuming
+ the previous engine's output — so a generator engine must be listed
+ before the engine that executes what it generates.
+
+The key accepts three shapes:
+
+```yaml
+engine: knitr # scalar: a one-engine sequence
+engine: [knitr, jupyter] # array: N engines, in order
+engine:
+ - jupyter: # entry with config: the map's value
+ kernel: python3 # is threaded to the engine at
+ # execute time
+```
+
+A per-entry config may also carry one reserved key, `claims:`, a
+document-level **claim table** for the listed engine (see §3 — the table
+semantics are identical on both keys). `claims:` is resolution metadata,
+not engine configuration, so q2 strips it before the config reaches the
+engine.
+
+One q2-specific behavior deserves care: `engine:` is read from **merged
+metadata**, so a project-level `engine:` in `_quarto.yml` works and
+concatenates with a document's own list. When both layers name the same
+engine, the *project's* entry wins (array layers concatenate project-first
+and deduplication keeps the first occurrence); q2 warns about the conflict
+and points at the `!prefer` merge tag for documents that need to override.
+If what you actually wanted at the project level was engine
+*configuration* — not forcing every document's sequence — the right key is
+`engines:`, which is the subject of the next section.
+
+## 3. `engines:` — configuring engines without naming any
+
+The project-level `engines:` key configures the **engine registry**: the
+pool of engines (built-ins plus discovered extensions) that resolution
+draws from. Writing it never puts an engine into play. Its guarantees are
+the mirror image of `engine:`'s consequences, and they are worth stating
+as guarantees because they are what make the key safe to use project-wide:
+
+- it never makes any document's sequence explicit (the implicit-fallback
+ tier stays on);
+- it never makes an engine "present" (no interop side effects);
+- it never causes an engine to run that wouldn't have run anyway.
+
+The one selection-adjacent influence it retains is **ordering** — and it
+reaches a little further than a tie-break. The order engines are visited in
+(the *candidate order*: `engines:` entries first, then extensions in the
+order they were discovered, then the built-ins) does two jobs. It breaks
+equal-strength claim ties — when two engines make the same-kind,
+same-priority claim on a language, the earlier one wins it. And it sets the
+order in which the chosen engines actually run: a document with `{r}` cells
+knitr claims and `{julia}` cells a Julia extension claims runs them in
+candidate order — the extension, then knitr — **not** in the order the
+cells appear in the file. So `engines:` never *casts* anyone: claims decide
+who runs, and the implicit-fallback net still fills the gaps. It only sets
+the running order of whoever the claims chose. Configuration, not casting —
+the configuration just happens to include sequence.
+
+The key is a Q1-syntax-compatible array whose entries come in three forms:
+
+```yaml
+engines:
+ - knitr # string: ordering only
+ - path: ./my-engine.js # Q1's external-engine loader;
+ # reserved (see §4)
+ - legacy-python: # name-keyed map: per-engine config;
+ claims: [python] # `claims` is the only config key
+```
+
+The `claims:` value is a **claim table**: a complete replacement for the
+engine's `_extension.yml` `claims:` block, with the same schema and the
+same authority. "Complete" is the operative word — a table replaces the
+engine's *entire* claim surface, so a language absent from the table is
+simply not claimed (unless the table carries a universal `fallback:`
+entry, which claims everything at the fallback floor, exactly as it would
+in `_extension.yml`). Two consequences make tables the key's headline
+feature:
+
+- **A tabled engine is load-free.** Resolution answers its language
+ claims from the table without loading any code — which is what lets a
+ project's execution languages be resolved at index time, without ever
+ loading a legacy, claims-less extension, from one block of YAML and no
+ edit to the extension.
+- **An empty table is a mask.** `claims: []` means "this engine claims
+ nothing" — including, if you apply it to jupyter, disabling the
+ universal fallback project-wide.
+
+The full table semantics — source precedence, masking built-ins,
+priority-based forcing, validation policy — live in
+`engine-resolution.md` §3.3; this document only needs the shape.
+
+Two policies round out the key. A map entry naming an engine that is not
+in the registry is a **hard error at project load** — one failure, early,
+before any document is rendered, with Q1's message. And although `engines:`
+is a project-level key, q2 reads it from merged metadata, so a
+document-frontmatter `engines:` block also takes effect — an implementation
+artifact, not a supported surface; do not rely on it.
+
+## 4. Differences from Q1
+
+Readers coming from Quarto 1 should unlearn three things.
+
+**Q1's `engine:` chose one engine; q2's chooses a sequence.** In Q1 the
+key (or a top-level engine-name shorthand like `jupyter: python3`) named
+the single engine that ran the whole document. q2 keeps every Q1 spelling
+and generalizes the meaning: the array form declares N engines that run in
+order, with per-language ownership divided among them by the resolution
+tiers. A second, quieter difference hides in *where* the key is read: Q1
+consulted only the file's own frontmatter, so an `engine:` in
+`_quarto.yml` was silently inert; q2 reads merged metadata, so the project
+layer participates (with the project-wins-on-duplicates rule from §2).
+
+**Q1's `engines:` did loading and ordering; q2's does configuration and
+ordering.** Q1's entries were engine names (ordering) and `{path: ...}`
+objects that dynamically imported external engine modules. q2 replaces the
+loading role entirely — engines arrive via `_extensions/` discovery, and
+`path:` entries are reserved rather than honored — and adds a role Q1
+never had: per-engine configuration via claim tables. The ordering role
+carries over unchanged in spirit (user-listed engines are consulted
+first).
+
+**The selection/configuration boundary is sharper in q2.** Q1's `engines:`
+order could effectively *select* the winner because Q1's claims were bare
+scores and ties were common. q2's kind-tagged claims (`Primary` /
+`Interop` / `Fallback`, kind dominating priority) make ordering rarely
+*decide a contest* — a genuine equal-kind, equal-priority tie is uncommon.
+What ordering still always does is set the **run order** of a multi-engine
+sequence (§3): *who* runs is decided by `engine:` and by claims, but the
+order they run in is candidate order, which `engines:` shapes. So `engines:`
+stays on the configuration side of the line — it just configures sequence as
+well as claims.
+
+## 5. History of the two keys in q2 (condensed)
+
+**`engine:`** has existed since the first engine-detection commit
+(2026-01-07, `748856f50`) — already scalar, config-map, and shorthand
+forms — and gained the array form with sequential multi-engine execution
+(2026-05-29, `f34c20dbb`, #238). The epic reserves one key inside an
+entry's config, `claims:`, a document-level claim table that replaces the
+engine's claims (§2).
+
+**`engines:`** had no q2 meaning before the TS Engines Epic — a project
+that set it was setting an inert key. The epic gives it three roles: a
+verbatim **wire pass-through** to TS engines (for Q1-API parity — engines
+may read `project.config.engines` — narrowed to names), the **claim-table
+entries** that are its first Rust-side semantics, and the **ordering
+splice** in `build_engine_registry`.
+
+## 6. Choosing between them: three worked one-liners
+
+- *"This document should run knitr, then jupyter."* → `engine: [knitr,
+ jupyter]` in the document. You are naming participants.
+- *"Our legacy extension has no static claims and the language server
+ can't index our project."* → in `_quarto.yml`:
+ `engines: [{legacy-python: {claims: [python]}}]`. You are configuring a
+ participant; every document resolves at index time and no document's
+ sequence changes.
+- *"knitr keeps grabbing our python cells via reticulate, project-wide."*
+ → `engines: [{knitr: {claims: {r: primary}}}]`. A whole-table
+ replacement that omits python — masking, again without touching any
+ document's cast.
diff --git a/claude-notes/designs/engine-api-surface.md b/claude-notes/designs/engine-api-surface.md
new file mode 100644
index 000000000..6dddcda5b
--- /dev/null
+++ b/claude-notes/designs/engine-api-surface.md
@@ -0,0 +1,226 @@
+# Engine API surface — q2 wire vs the full Q1 engine surface
+
+**Extracted from** `claude-notes/plans/2026-06-25-plan1a-return-to-q1.md` (RTQ) on 2026-06-26.
+This is the surface-coverage record — the field-by-field / method-by-method audit of the q2
+engine wire against the full Q1 engine surface, plus the framework decisions (DQ-1 … DQ-7), all
+**resolved 2026-06-29**. RTQ carries the actionable protocol/code items (Item A, ENG-1, FC-1, FC-2);
+this doc records the surface classification and the decisions behind it.
+
+**Companion (consumed-surface model):** this doc audits the engine-**PROVIDED** / wire half (what
+q2 *sends* and *receives*). The **CONSUMED** half — the `quarto..` calls engines make
+*back* — is modeled in `claude-notes/research/2026-06-26-engine-api-usage-model.md`. The two are
+two sides of the same surface.
+
+## Governing principle — the validation target is not the scope boundary
+
+**Carry the whole Q1 engine protocol surface. The scope boundary is the *Q1 engine API*, not what
+the current *validation target* (Julia, single-doc, single-engine) happens to exercise.** "Defer
+features, not infrastructure" is the operational form of this: where Q1 exposes an engine
+method / field / flag, the q2 **infrastructure** to carry it is in scope **now** (classified
+`build-infra` or `defer-infra` with a recorded seam below), even with zero current callers. Only
+`drop` when q2's architecture makes it impossible or redundant — *with a reason*. A feature (no q2
+producer/consumer yet) may be deferred; the protocol that would carry it may not be silently
+narrowed.
+
+**The failure mode this prevents.** The 1a/1b plans were drafted against the Julia render path and
+repeatedly scoped the wire to *what that path needs*, dropping protocol surface no Julia render
+touches. Because the drops are invisible on the Julia validation target, they pass every test and
+surface only later as a coordinated retrofit. Two confirmed instances — **the same class of bug
+twice**:
+
+- **`system.execProcess` params** (RTQ F1 / `2026-06-26-plan2a-review-findings.md` §2a-1): q2
+ reduced the signature to `(options, stdin?)`, dropping `mergeOutput`/`stderrFilter` because *no
+ TS-extension* uses them — but **knitr does** (`rmd.ts:440-458`), and the SDK is advertised as
+ Q1-consumable. *Consumed-surface half; lives in the usage model.*
+- **`dependencies` flag + deferred-deps fold** (1B-DEPS-2 / `2026-06-26-1b-vs-usage-model-reconciled.md`):
+ 1b hardcoded `dependencies:true` + a dead fold because *no single-file book exists yet* — dropping
+ the deferred path, which is Q1 **protocol** (its first consumer is single-file book rendering,
+ `book-render.ts:136`). *Provided-surface half — note this doc already classes the flag
+ `build-infra` (Level 1); 1b diverged from that classification.*
+
+Both are Julia-invisible (Julia's `execProcess` unused; Julia's `dependencies()` a no-op) — exactly
+why a Julia-only review misses them.
+
+A third near-instance — **caught before landing**, recorded so the pattern stays visible:
+
+- **Static-claim schema expressiveness** (plan1c review, 2026-06-28): the D1 static-claims schema
+ was first shaped to the **echo** fixture (language-only + extension-only) and initially could not
+ express marimo's `first_class`-conditional claim (`{python .marimo}`). Fixed before landing by
+ adding `whenClass:` — `claims_language` is a pure function of `(language, first_class)`, so it
+ tabulates fully — so the static surface is **not** narrowed. This review originally recorded a
+ residue — **content-inspecting `claims_file`** (Julia's `# %%`) — as the one place static resolution
+ was strictly less powerful. **Corrected 2026-07-07:** a full Q1 census showed every content sniff is
+ `extension-gate → read-file → one regex` (a pure function of file bytes), so it *is* statically
+ declarable — as a `content-pattern` on a `claims-files` entry, evaluated natively (Plan 7a). There is
+ **no** static-vs-dynamic residue in practice; the dynamic `claims_file` method survives only as a
+ fallback for a hypothetical non-regex-expressible sniff (empty across every known engine). See
+ engine-resolution.md §3.3 and
+ [Plan 7a](../plans/2026-07-07-plan7a-static-content-pattern-claims.md).
+
+**The author test.** For each Q1 engine method / field / flag, do **not** ask "does the Julia
+validation target need this?" Ask: *does the Q1 engine API expose it as protocol, and could a
+non-Julia engine or a not-yet-built render mode (books, manuscripts, serve, multi-engine) use it?*
+If yes → infrastructure is in scope now (`build-infra`/`defer-infra` + seam). The validation target
+proves the framework works; it does not define the framework's surface.
+
+## Surface coverage audit — q2 wire vs the full Q1 engine surface
+
+Both levels, **Q1 read directly** (`execute/types.ts:35-243`, `project/types.ts:164-216`, the
+lifecycle model). The original 1a wire carried the Julia render path and omitted the rest of the
+engine surface. Per "defer features, not infrastructure," each Q1 engine method/field is classed:
+**present** (on the wire) · **build-infra** (framework should carry now; q2 consumer may stub) ·
+**defer-infra** (real but premature — record the seam) · **drop** (q2 architecture makes it
+impossible/redundant, reason given).
+
+### Level 1 — inbound options & context (field-by-field)
+
+**`ExecuteOptions` → `TsExecuteOptions`:**
+
+| Q1 field | q2 | class | note |
+|---|---|---|---|
+| `target: ExecutionTarget` | flattened → `input`/`source_path`/`source_map` | **drop** | no `target()` step (DQ-3); cookie + `data` absent — `engine_state` added only if a round-trip later needs it |
+| `format` | `format` | present | |
+| `resourceDir` | `Init { global }` (ambient, Item A) | present | |
+| `tempDir`/`cwd`/`libDir?` | `temp_dir`/`cwd`/`lib_dir` | present | |
+| `dependencies: boolean` | wire flag (default `true`) | **build now** | build the round-trip — flag + `Dependencies` verb + `engineDependencies` (FC-2); orchestrator-driven, harness fold deleted (DQ-2) |
+| `projectDir?` | `project_dir` | present | |
+| `params?`/`quiet?` | `params`/`quiet` | present | |
+| `previewServer?` | — | defer | run/serve — deferred behind a seam (DQ-2) |
+| `handledLanguages` | `handled_languages` | present | leave-alone set (§5); `HANDLED_LANGUAGES ∪ {lang owned by others}` |
+| `project: ProjectContext` | `project_dir` + launch `EngineProjectContext` | present | `config` + output-dir carried as values (DQ-5); the two callback members dropped (DQ-1) |
+| — | `source_map` | q2-native | provenance addition |
+| — | `owned_languages` | q2-native | positive projection of the ownership map (§5, Plan 4d) — `{lang owned by this engine}`; informational (not enforcement), so engines select owned cells directly instead of inferring the complement of `handledLanguages` |
+
+**`ExecutionTarget`** (Q1 cookie; q2 has **no `target()` step**):
+
+| Q1 field | q2 | class | note |
+|---|---|---|---|
+| `source`/`input`/`markdown` | `source_path`/`input` (resolved markdown pushed) | present | |
+| `metadata` | folded into `format.metadata` | verify | confirm target-vs-format metadata overlap (residual verify, not a design question) |
+| `data?` (engine cookie) | — (q2 uses `engine_config` + lazy resolve in execute) | **drop** | no `target()` (DQ-3); jupyter `{transient, kernelspec}` resolved lazily in execute |
+| `preEngineExecuteResults?` | — | defer-infra | cell-handler pre-results |
+
+**`EngineProjectContext`** (passed to `launch()` after Item A; q2 carries only `{dir, isSingleFile}`):
+
+| Q1 member | q2 | class | note |
+|---|---|---|---|
+| `dir` / `isSingleFile` | present | present | |
+| `config?` (`engines`, `output-dir`) | carry as values on launch ctx | **build now** | cheap serializable values (DQ-5) |
+| `getOutputDirectory()` | pass output dir as a value | **build now** | not a callback — a value on the launch ctx (DQ-5) |
+| `fileInformationCache` | — | **drop** | host-owned live `Map`, not serializable; sole engine read is jupyter `keep-ipynb` transient tracking via `target.data` — a cookie q2 drops (DQ-3) and a file-lifecycle q2 owns host-side (DQ-1) |
+| `resolveFullMarkdownForFile()` | — (q2 pushes resolved `input`) | **drop** | push model; the callback was used only by obsolete engine methods (DQ-1) |
+
+### Level 2 — method surface
+
+**Discovery (`ExecutionEngineDiscovery`):**
+
+| Q1 member | q2 | class | note |
+|---|---|---|---|
+| `init?`/`name`/`validExtensions`/`claimsFile`/`claimsLanguage`/`launch` | present | present | |
+| `generatesFigures` | → `LoadEngineResult` (discovery) | **build now** | move to discovery tier — ENG-1 (DQ-4) |
+| `canFreeze` | → `LoadEngineResult` **and** instance | **build now** | add to discovery for freeze-planning; keep on instance (Q1 has both) — DQ-4 |
+| `quartoRequired?` | → `LoadEngineResult` (discovery) | **build now** | load-time semver gate (grand-plan Phase 12) — DQ-4 |
+| `ignoreDirs?` | — | out of scope | project file-walk — out of the render wire (DQ-6) |
+| `defaultExt`/`defaultYaml`/`defaultContent` | — | out of scope | scaffolding (`quarto create`) — own command surface (DQ-6) |
+| `checkInstallation?` | — | out of scope | `quarto check ` — own command surface (DQ-6) |
+| `populateCommand?` | — | **own command surface** (was "drop") | `q2 call engine …` — see Plan 9 correction below (DQ-6) |
+
+**Instance (`ExecutionEngineInstance`):**
+
+| Q1 method | q2 | class | note |
+|---|---|---|---|
+| `markdownForFile`/`execute`/`intermediateFiles?` | present | present | |
+| `target` | — | **drop** | per-file cookie folded into execute; no `target()` step (DQ-3) |
+| `dependencies` | → wire verb | **build now** | deferred-deps round-trip built — FC-2 (DQ-2) |
+| `postprocess` | — | **drop** | recover via an **AST transform** that reads FC-1's already-carried `preserve` field (the No-DOM-postprocessor rule). Connected seam: **FC-1 (carries `preserve`/`post_process`) ↔ this dropped `postprocess` hook ↔ Plan 3's `removeAndPreserveHtml` producer** (`quarto-jupyter.md:134-136`). (DQ-2) |
+| `partitionedMarkdown` | — | **drop** | pampa parses qmd natively after `markdownForFile`; engine partition is redundant |
+| `filterFormat?` | — | defer | engine influences `Format` pre-render — behind a seam |
+| `run?`/`postRender?` | — | defer | serve/preview + after-render (server-backed engines) — documented seam (DQ-2) |
+| `canKeepSource?`/`executeTargetSkipped?` | — | defer-infra | keep-md / freeze-skip hooks |
+
+### Resolved framework decisions (DQ-1 … DQ-7)
+
+These were the open framework questions in the 2026-06-25/26 sessions; **all are decided
+(2026-06-29).** Recorded here as resolved facts — the per-surface tables above carry the same
+outcomes inline. The actionable protocol/code changes live in RTQ
+(`2026-06-25-plan1a-return-to-q1.md`) as **Item A, ENG-1, FC-1, FC-2**.
+
+- **DQ-1 — engine→host callbacks → push model; callbacks dropped.** q2 pushes fully-resolved `input`
+ markdown into execute, so `resolveFullMarkdownForFile` is unnecessary (Q1 engines called it only
+ from obsolete methods). `fileInformationCache` drops with it: it is a host-owned live
+ `Map`, not serializable across the subprocess; its sole engine reader is
+ jupyter's `keep-ipynb` transient-notebook bookkeeping, which mutates `target.data` — a cookie q2
+ drops (DQ-3) and a file-lifecycle q2 owns host-side. **No `FromEngine`-initiated callback channel
+ in v1** (re-entrancy/deadlock risk on the single Deno thread; no consumer).
+- **DQ-2 — render lifecycle → build the `dependencies` round-trip (FC-2), orchestrator-driven; drop
+ `postprocess`; defer `run`/`postRender`.** The wire gains `dependencies: bool` (default `true`) +
+ `ToEngine::Dependencies`/`FromEngine::DependenciesResult` + `engineDependencies` on the result;
+ **q2's render orchestrator drives the deferred resolution** (mirrors Q1 `render.ts:90-109`), **not**
+ a harness-internal fold. `postprocess` is dropped (no post-write DOM stage; recover via an AST
+ transform reading FC-1's `preserve`). `run`/`postRender` deferred behind a documented seam.
+- **DQ-3 — per-file engine-state cookie → no `target()` step in v1.** q2's lazy-resolve +
+ `engine_config` is sufficient; add an opaque `engine_state` field **only if** a future
+ `dependencies` round-trip forces an engine to thread state across it.
+- **DQ-4 — discovery-tier completeness → expand `LoadEngineResult` to
+ `{name, valid_extensions, generates_figures, can_freeze, quarto_required}`.** All static,
+ cheap-at-load, read pre-launch by resolution / freeze-planning / version-gating. ENG-1 is the first
+ landed slice (`generates_figures` + `can_freeze`); `canFreeze` stays on the instance result too
+ (Q1 has it at both tiers).
+- **DQ-5 — `EngineProjectContext` completeness → carry `config` (`engines` + project `output-dir`)
+ and the output directory as values** on the launch context. The two callback members are DQ-1
+ (dropped).
+- **DQ-6 — non-render surfaces → out of the render wire.** `populateCommand`,
+ `defaultExt`/`defaultYaml`/`defaultContent` (scaffolding), `checkInstallation` (check),
+ `ignoreDirs` (project walk) belong to their own command surfaces — designed when those commands
+ grow engine-awareness, not bolted onto the execute protocol.
+ - **`populateCommand` correction (Plan 9, 2026-07-03, bd-m1jeqhhz).** This was
+ originally filed a *hard drop* ("cliffy subcommands into a Rust CLI —
+ impossible"). That is wrong: `q2 call engine …` runs the engine's
+ *own* cliffy `populateCommand` in a short-lived Deno process (the `call-engine`
+ host-bundle mode, vendored cliffy) with inherited stdio, giving byte-for-byte Q1
+ parity. It stays true to DQ-6 — it is its **own command surface**, off the render
+ wire (no new protocol verb; a separate one-shot process, not the shared render
+ host). The trait side is an additive `call_engine_command` default. See
+ `claude-notes/plans/2026-07-03-plan9-call-engine.md` and
+ `claude-notes/research/2026-07-03-plan9-call-engine-research.md`.
+- **DQ-7 — Init/launch split → `Init { global }` once per subprocess (process-stable config);
+ project context rides `LaunchEngine` per render** (Item A). This **supersedes** the earlier
+ "`Init { global, project }`" recommendation: moving project context to `LaunchEngine` is strictly
+ more Q1-faithful (`engine.launch(EngineProjectContext)`) and is the enabler for reusing one
+ subprocess across renders (`Init` stays process-stable; each render's `launchEngine` carries its
+ own project context).
+
+### Build checklist (decision → landing item)
+
+All boxes are **unimplemented** — RTQ is plan-only, not yet executed. Each box is the protocol/code
+change a decision requires; the **owning RTQ item** is named so this stays a coverage map, not a
+competing source of truth. A box marked **(no owner)** has no RTQ item yet and must get one in the
+consolidation pass.
+
+- [ ] **DQ-1** — push resolved `input`; no `FromEngine` callback channel; harness builds a
+ *harness-local* `fileInformationCache` (not a wire carrier) — *RTQ Item A (plan1b harness)*
+- [ ] **DQ-2** — `dependencies: bool` (default `true`) + `ToEngine::Dependencies` /
+ `FromEngine::DependenciesResult` + `engineDependencies` on the result; orchestrator-driven;
+ delete the harness fold — *RTQ FC-2*
+- [ ] **DQ-2** — drop `postprocess`; carry `preserve`/`post_process` and recover via an AST transform
+ — *RTQ FC-1 (carrier) + B2 (recovery story) + PROTO-1 (disposition)*
+- [ ] **DQ-4** — move `generates_figures` → `LoadEngineResult`; add `can_freeze` there (keep on the
+ instance result) — *RTQ ENG-1*
+- [ ] **DQ-4** — add `quarto_required: Option` to `LoadEngineResult` (discovery tier). Splits
+ in two: **field → fold into ENG-1** (same `ts_protocol.rs` / harness / test-helper change as the
+ DQ-4 tier completion — carry it inert, no v1 engine sets it); **load-time semver gate →
+ grand-plan Phase 12** (reuse its `semver` / `VersionReq` / `cli_version()` machinery). Q1:
+ `quartoRequired?` on `ExecutionEngineDiscovery` (`execute/types.ts:65`), gated by
+ `checkEngineVersionRequirement` (`engine.ts:61`, **hard throw**; cf. Phase 12's extension-YAML
+ `quarto-required`, which **warns** — q2 must pick one severity).
+- [x] **DQ-5** — carry `config` (`engines` + project `output-dir`) and the output directory as
+ *values* on `LaunchEngine.project` — *RTQ Item A* (Plan 1c.2 P1.1, 2026-07-02: wired in
+ `build_engine_registry`; wire `config` carries `engines` + **flat** top-level `output-dir` —
+ the host's `reconstructRichProject` bridges it into the rich `config.project.outputDir`)
+- [ ] **DQ-7** — `Init { global }` once per subprocess; project context on `LaunchEngine` per render
+ — *RTQ Item A (sequence its `ts_protocol.rs` edit with ENG-1)*
+
+**Decided, no build (recorded for completeness):** DQ-2 `run`/`postRender` — deferred behind a
+documented seam (name it in RTQ/grand-plan, no code now); DQ-3 — no `target()` step, add
+`engine_state` only if a future round-trip needs it; DQ-6 — non-render surfaces stay off the render
+wire.
diff --git a/claude-notes/designs/engine-host-concurrency.md b/claude-notes/designs/engine-host-concurrency.md
new file mode 100644
index 000000000..b13a64f06
--- /dev/null
+++ b/claude-notes/designs/engine-host-concurrency.md
@@ -0,0 +1,270 @@
+# Engine-host concurrency: async multiplexing over one Deno subprocess
+
+**Status:** canonical reference for the TS-engine subprocess concurrency model.
+Pointed at by plan1a-protocol (Phase 1.5), plan1a-host, plan1a-engine, and
+Plan 1b.
+
+**Why this exists.** The original Plan 1a design assumed a **lockstep**
+request/response protocol over stdio: one request, one response, no
+correlation, serialized by a single `Mutex`, with whole-subprocess
+SIGKILL on timeout/cancel. That was correct while **Pass-2 (per-file render)
+was serial**. Pass-2 is now **parallel** (rayon + `pollster`-per-worker), and
+all workers share **one Deno subprocess per project render**. Under the lockstep
+model that means (a) every engine round-trip serializes through one lock, and
+(b) one document's timeout/cancel SIGKILLs the subprocess out from under
+siblings still mid-`execute`. This note defines the replacement.
+
+## The key realization
+
+**Deno is single-threaded but asynchronous.** One process can hold many
+requests in flight at once — engine A's `execute()` parked on a Julia daemon
+socket while engine B's is parked on a Jupyter kernel — with the event loop
+interleaving them. Nothing about "one subprocess" forces serialization; only
+our **framing** did (**one-in-flight, no correlation** — independent of which
+OS channel carries it). The fix is to reframe the one process as an **async
+multiplexed RPC channel** over a dedicated transport — originally the
+*existing* stdin/stdout (v1), and since Plan 1a.6 a private loopback-TCP
+socket (see "Phase 1.6" below) — not to spawn N processes (which wastes
+exactly that async capability and N×s the memory).
+
+## Architecture (three layers)
+
+### 1. Protocol (plan1a-protocol Phase 1.5)
+
+- **Correlation envelope.** Every frame carries a monotonic `id: u64`; the
+ response echoes it. Nested envelope (`{ id, msg: {type, …} }`), not
+ `serde(flatten)` — flatten round-trips poorly with internally-tagged enums.
+ The existing `ToEngine`/`FromEngine` enums are unchanged.
+- **Cooperative cancel.** `ToEngine::Cancel { target: u64 }` (fire-and-forget,
+ references an in-flight id) and `FromEngine::Cancelled {}` (delivered under
+ the target id).
+- **Channel: stdio in the original v1 design; loopback TCP since Plan 1a.6.**
+ The envelope + `Cancel` are all that parallel Pass-2 *requires* — they
+ multiplex fine over a JSON-lines channel (the harness write-serializes its
+ frames; single-threaded Deno writes each line atomically, so frames never
+ interleave), independent of which OS channel carries it. In the original v1
+ design, stdout was the protocol channel, so the "stdout is protocol; a stray
+ `console.log`/non-JSON line is malformed → kill" contract held at the time
+ (owned by plan1a-host), and it was two-sided: stdin was the protocol *input*,
+ so an engine reading `Deno.stdin` stole frames just as writing to stdout
+ corrupted output. **That contract is gone.** Plan 1a.6 has since landed (see
+ "Phase 1.6" below and
+ `claude-notes/plans/2026-07-08-plan1a6-off-stdout-loopback-tcp.md`): the
+ protocol now rides a private loopback-TCP socket, and stdout/stderr are
+ diagnostic-only — a stray `console.log` no longer corrupts anything. The
+ bidirectional **continuous drain** (q2's demux reader thread + the harness's
+ non-blocking loop) is still what keeps a large `Execute` payload from
+ deadlocking the channel — load-bearing regardless of transport, not
+ optional. The `EngineTransport` trait already abstracted the channel, which
+ is why the swap to TCP was localized.
+
+### 2. Rust host — a demux, not a Mutex (plan1a-host)
+
+Standard async-RPC-over-a-pipe, composed entirely from **blocking std
+primitives** so it fits blocking rayon+`pollster` workers:
+
+- `TsEngineHost` owns a **write half behind a short-lived mutex** (held only for
+ the microseconds of one framed write) and a **`pending: Mutex>`**.
+- One **reader thread** owns the read half (the accepted loopback-TCP socket's
+ read half since Plan 1a.6; the child's stdout in the original v1 design), parses
+ each `Response`, reads its `id`, and delivers to `pending.remove(id)`. A
+ response whose `id` is no longer pending (late reply after a cancel) is
+ dropped.
+- A worker calls `host.request(msg, window, &cancellation)`: allocate `id` →
+ register a blocking `Slot` (e.g. `sync_channel(1)`) → write the framed line
+ under the write-mutex → block on the slot with `recv_timeout`, polling
+ `is_cancelled()` on each tick. On timeout/cancel it sends `Cancel { target:
+ id }` and resolves the slot with a cancelled/timeout error.
+
+**The crucial property: no lock is held across the wait.** Worker A blocking on
+a 5-minute `Execute` does not block worker B's discovery call — they hold
+different slots; the write-mutex and pending-mutex are each held for
+microseconds. Head-of-line blocking is gone.
+
+### 3. Deno harness — a non-blocking read loop (Plan 1b)
+
+```ts
+for await (const frame of readFrames(conn)) { // originally Deno.stdin (v1); now the loopback-TCP conn (Plan 1a.6)
+ if (frame.msg.type === "cancel") { abort(frame.msg.target); continue; }
+ dispatch(frame); // fire-and-forget; the loop does NOT await engine work
+}
+```
+
+`dispatch` runs the request **serialized per engine instance** (chained on a
+per-engine promise queue) and, when done, writes `{ id, msg: result }` to the
+protocol channel (the loopback-TCP connection since Plan 1a.6; the captured
+`Deno.stdout` in the original v1 design) under a write-mutex. Because
+the read loop never awaits engine work, requests to *different* engines run
+concurrently on the event loop; the per-engine queue serializes requests to the
+*same* instance. An `AbortController` per `id` implements `Cancel`.
+
+## The concurrency ceiling (and why it's physics, not a compromise)
+
+- **Document pipeline** (parse → merge → transforms → write): fully parallel
+ across workers, always — it never touches the subprocess.
+- **Cross-engine execution:** parallel (distinct daemons, interleaved on the
+ Deno event loop).
+- **Same-engine-instance execution:** serial — and this is **forced by the
+ daemon, not chosen.** There is one `julia` instance per render talking to one
+ Julia daemon (single fixed `julia_transport.txt` per runtime dir); Jupyter
+ shares a kernel keyed by (kernelspec, target). Two concurrent Julia documents
+ *cannot* truly parallelize their execute — they'd be two render requests to
+ one kernel. Serializing same-engine costs nothing we could have had anyway.
+
+Escape hatch if same-engine parallelism ever matters: instance-per-worker (N
+kernels). Not built now — for Julia it wouldn't even help (single shared
+daemon).
+
+## Cancellation / timeout — contained blast radius
+
+A per-document timeout/cancel **must not SIGKILL the shared subprocess** (that
+murders sibling documents). So:
+
+1. Per-request timeout/cancel → send `Cancel { target: id }` → harness aborts
+ *that task's* `AbortSignal`. Contained to one request.
+2. **Poison policy — scoped by request type, not by guessing daemon state.**
+ Rust cannot distinguish "ambiguous" from "clean" (the daemon is a separate
+ process; aborting the JS-side `AbortSignal` never proves it went idle). So
+ don't classify — scope by which request was interrupted. **`Execute` is the
+ only daemon-engaging request v1 issues** (this is a v1 fact, not an absolute:
+ Q1's `dependencies()`/`run()` also spawn subprocesses — knitr's `Rscript`,
+ `rmd.ts:407-489` — so the poison scope must be revisited if/when those
+ requests are added), so cancel/timeout of an `Execute` **always poisons that
+ engine instance**; every other request engages no daemon and is just failed. Poison = invalidate the instance on both sides (harness drops
+ its `instance` entry; `TsEngine` clears its cached launched-state — which is
+ why that cache is a clearable `Mutex