feat(export): kb-export-subgraph-html — whole-KB interactive HTML export - #567
Merged
Conversation
Export a KB subgraph (seed node + neighborhood) to one self-contained, offline-first HTML file: force-directed layout baked in at export time (mae-canvas::ForceLayout, no JS physics shipped), gruvbox-themed inline CSS/SVG/JS with hover preview, click-to-select detail panel, an anchor-node "Start here" reading-order walk, an optional bilingual EN/ES toggle driven by an additive external translations JSON file, and mermaid diagram pre-rendering to inline SVG via `npx @mermaid-js/mermaid-cli` (falls back to raw source on render failure). New crates/export/src/html_graph.rs (HtmlGraphExporter) follows html.rs's existing "one function, one dependency-free HTML string" shape. Wired as the kb_export_subgraph_html MCP tool (crates/ai) and the :kb-export-html colon command, matching kb_export_guidance's existing precedent for both. Classified SingleTarget in the AI-residency gate (crates/mae/src/ai_residency.rs) since extract_subgraph is single-KB-instance by construction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…heming Mid-flight design update on the kb_export_subgraph_html widget: swap the baked layout from force-directed to a chord ring (mae_canvas::kb_graph::build_kb_graph_chord_positions, edges drawn as interior arcs and independently clickable to follow a link to the other endpoint), add a persistent Home button (jumps to the anchor/spine node, which is now auto-selected on load), a collapsible "on this page" outline sidebar scanned from the current node's own rendered headings, and a manual dark/light theme toggle alongside the existing prefers-color-scheme support. Restyle to a muted default (--fg4 nodes/--gray edges) with a single validated emphasis accent (GruvboxPalette::accent, gruvbox's own bright_orange/orange, contrast-checked at 5.84:1 / 3.41:1 against each theme's own bg0) reserved for the current node + its incident edges — the 14 per-kind hues failed a categorical-palette simultaneous-discrimination check for a widget this size, so kind is now conveyed via the detail panel's kind badge instead of per-node fill. Hover lifts (scale + shadow) rather than recoloring, so hover and selection no longer compete for the same visual channel. Detail-panel/link-list/popover construction switched from string-concatenated innerHTML to textContent/createElement, with body_en/body_es (pre-rendered, already html_escape-safe org->HTML) as the one deliberate innerHTML assignment. Node/control hit targets floored at 24px. 150-250ms transitions on fill/stroke/transform/theme changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The graph-pane chord widget now renders in the opposite gruvbox mode from the surrounding page — a light inset on a dark page, a dark inset on a light page — toggling automatically with the existing theme switch. Deliberate design choice so the nav widget reads as a distinct focal point rather than blending into the page chrome, using #graph-pane's existing rounded-corner/shadow framing (already present) to make the inversion read as intentional composition. Mirrors render_css_variables's own default/media-query/attribute structure exactly, just inverted and scoped to #graph-pane, so an explicit theme-toggle click wins over system preference the same way it already does for the rest of the page. Reuses the two accent values already validated in the prior commit (#fe8019 dark / #d65d0e light), just applied to whichever surface the widget actually lands on rather than the page's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…IES drawers Running kb_export_subgraph_html against a real KB (RoamNotes) surfaced two real content bugs neither hand-built test fixture had exercised: 1. mae_kb's org parser canonicalizes every `[[id:UUID][label]]` link into `[[UUID|label]]` before storage (shared/kb/src/org.rs's own documented "internal pipe-display convention") -- but parse_org_link_str only understood raw org-file `[[target][label]]` syntax. Every internal link in a real node body rendered as a garbled, unsplit "UUID|label" string used as both href and visible text. Fixed by recognizing `|` as an equally valid separator (safe/non-regressive: standard org-mode never puts a bare `|` inside a single [[...]] bracket pair), stripping any surviving `id:` prefix, and giving internal (non-URL) targets a `#`-prefixed href instead of an invalid bare-UUID one. External links and genuine two-bracket-group raw-org syntax (html.rs's HtmlExporter's own use case) are unaffected -- covered by a new non-regression test. 2. Every real node's rendered body opened with its own raw `:PROPERTIES: :ID: ... :hash: ... :END:` drawer dumped as visible prose, because parse_org_document (written for org body content) has no concept of a drawer. Fixed with a bounded leading-drawer strip in render_node_body_html and plain_text_preview, mirroring the same bounded-prefix convention shared/kb/src/activity.rs's body_hash already uses for this exact drawer shape. Four new regression tests, found by actually running the export against real content rather than only hand-built fixtures -- worth noting as a gap in the original test suite, not just these two bugs. Full mae-export (81) + mae-ai (629) suites still pass, clippy clean, fmt clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…conversion convert_inline_markup_str's main loop decoded one raw byte at a time (`bytes[i] as char`, `i += 1`) for any character that wasn't an ASCII markup trigger. Every non-ASCII character -- an em-dash, any accented character -- got split into its 2-4 individual UTF-8 bytes, each reinterpreted as a bogus Latin-1-ish char (e.g. "configuración" -> "configuración"). Found by actually running the real Spanish translations through this path via kb_export_subgraph_html against RoamNotes: every accented word in every one of the 12 translated nodes came out mangled, and English em-dashes rendered as "â". The ASCII-marker dispatch (`*`/`/`/`~`/`=`/`+`/`[`) itself was never wrong -- no ASCII byte value can collide with a UTF-8 continuation (0x80-0xBF) or leading (0xC0-0xF4) byte, so byte-position marker search/slicing stays correct even with multibyte content nearby. Only the fallback "ordinary character" path needed fixing: decode the real Unicode scalar at `i` and advance by its `len_utf8()`, instead of reusing the byte-cast `ch`. New regression test with real Spanish content. Full mae-export (82) + mae-ai (629) suites pass, clippy clean, fmt clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…for them Running the export against real content (15 nodes, mostly one-hop from the anchor -- a star topology) surfaced a real readability problem a hand-built 2-3 node test fixture never would: every node's label rendered simultaneously, producing overlapping/illegible text, with several long titles clipped outright past the SVG viewBox edge (the fit-to-viewport math only accounted for node *positions*, never label width). Fixed at the root rather than patched around: per the dataviz skill's "selective direct labels" rule, only the anchor, the selected node, and whatever's under the cursor show a label by default (pure CSS, riding the `.selected`/`.hovered` classes already toggled elsewhere) -- every other node is an identifiable, clickable dot, its title one hover away via the existing popover. This removes almost all the collision at its source instead of fighting it with layout tricks. Labels that do show are also now truncated (28 chars + ellipsis, char-count not byte-length -- titles can be non-ASCII) as defense in depth, and the viewBox gets deliberately generous extra right-side room sized to the truncated-label budget so a still-visible label never clips past the diagram edge again. Verified visually (headless Chromium + Puppeteer, not just markup presence) in both themes and both languages after this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…view Real Firefox usage surfaced seven concrete problems -- fixed together since several touch the same layout/interaction surface: - Layout: #main-content (the node's rendered body) is now the dominant flex child; the chord diagram + outline share a narrow 300px #sidebar, chord fixed at 280px height above the outline, instead of the graph taking ~2/3 of the page width and the outline getting lost below a long body. Every DOM query in GRAPH_JS is by id, not position, so this was a pure markup/CSS change. - Chord-diagram labels get a background-colored SVG text halo (paint-order: stroke fill) instead of relying on raw fill-vs-bg contrast, which was technically fine in isolation (~6.8:1) but broke down wherever a label sat on top of the many edges converging on a hub node in a star-topology subgraph -- a real legibility problem, not a color bug, that only gets worse as the widget shrinks. - New --link CSS variable (gruvbox bright_blue/blue, contrast-validated: 5.48:1 dark / 3.73:1 light) for in-body <a> and popover links, which previously had no rule at all and fell through to the browser default blue/purple. Deliberately a different hue from --accent (orange already means "current node/edge" elsewhere on the page). - Previous/Next: walkIndex was a forward-only modulo counter behind one overloaded "Start here / Next" button. Replaced with two real, independently-disabled controls sharing one clamped (not wrapping) position. - plain_text_preview (feeds hover-popover previews) never had any concept of [[target|label]] link syntax, so a body containing an internal link showed raw brackets/pipe/UUID text verbatim. Fixed by adding InlineTarget::PlainText to the shared inline-markup parser (convert_inline_markup_str/parse_org_link_str) instead of writing a second link-parsing implementation -- links resolve to just their label, and the same parser is now reused by all three output shapes (HTML, Markdown, plain text). - New: hover-preview on in-body links (org-roam-ui-style), reusing the existing #popover/onHover/movePopover/nodesById machinery already built for chord-node hovering rather than new plumbing -- wiring, not a new feature surface. New regression tests for the link-color CSS var and the plain-text link-resolution fix. Full mae-export (84) + mae-ai (629) suites pass, clippy clean across mae-export/mae-ai/mae-core/mae, fmt clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… no silent truncation An independent quality/reusability review (this feature was built and tested primarily against one real 14-node use case) surfaced two real bugs, confirmed empirically rather than left as theoretical: - The chord widget's ">=24px hit target" claim was never actually true once the SVG's viewBox scales to fit its fixed-size container: world- space node radius was floored at a flat constant, but the ring's world extent grows with node count (sqrt-area layout) while the widget stays a fixed ~280-300px box, so preserveAspectRatio silently shrinks on-screen node size as a subgraph gets denser. Measured directly (headless Chromium + Puppeteer) on a synthetic 50-node star: on-screen radius as small as ~3px before this fix. Fixed by computing the real world-to-screen scale from the SVG's actual rendered size (getBoundingClientRect) and inflating the world-space floor to compensate, so the *on-screen* pixel result is what's guaranteed — confirmed empirically after the fix: ~12px radius floor holds at both 14 and 51 nodes. A first pass at this fix used Math.max(floor, degree-based-size), which technically satisfied the hit-target guarantee but silently flattened anchor/degree-based visual size differentiation back to a uniform floor at realistic node counts (confirmed on this repo's own real guide) — switched to an additive bonus on top of the floor instead, so both properties hold together. - `node_cap` (the safety net on reachable-set size) was hardcoded to 60 with no override and, more importantly, its truncation signal (`SubgraphResult::hidden_node_count` — already computed by extract_subgraph and already surfaced by two other call sites in this codebase) was silently dropped: a caller hitting the cap got a perfectly normal-looking "Exported 60 nodes..." success message with no indication the true reachable set was larger. Now overridable (arg `node_cap`, still capped at 200 pending widget-sizing work for anything larger) and the status string says so explicitly when truncation happens, matching the "never truncate silently" convention already established elsewhere (graph_view_ops.rs's identical pattern). Also updated the depth-clamp doc comment (was undocumented magic number, unlike node_cap's) and the kb_export_subgraph_html tool definition's description, which still said "force-directed layout" from before this session's earlier switch to the chord-ring layout. Two new regression tests (node_cap truncation + reporting, node_cap override + default) plus one confirming the hit-target fix's code path is present (the actual on-screen-pixel behavior needs a real browser and was verified that way this session, documented in the test). Full mae-ai (631) + mae-export (85) suites pass, clippy clean across mae-ai/mae-export/mae-core/mae, fmt clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found during final real-browser verification: the scoped inverted-
theme CSS variables (render_widget_inverse_theme_css) were correct --
confirmed by an existing test -- but nothing applied `background:
var(--bg0)` to #graph-pane itself, so it stayed transparent and the
page's real (non-inverted) background showed through underneath. The
"light window inset in a dark page" effect never actually happened
visually, only the variables were right. Confirmed empirically both
before (computed background-color: rgba(0,0,0,0)) and after (a real,
correctly-inverted color in both theme directions) via headless
Chromium.
New regression test anchored on a unique marker in the sizing rule
(several #graph-pane { ... } blocks exist -- the inversion variable
blocks plus this one -- so a plain substring split would have matched
the wrong one, and did on the first attempt). Full mae-ai (631) +
mae-export (86) suites pass, clippy clean, fmt clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… navigate, inline code visible Three more real bugs found in follow-up review of the actual page: - walkIndex started at -1 with a "Start here" label, but the anchor is already auto-selected on page load and readingOrder[0] is always the anchor itself (BFS distance 0 from itself) -- so the very first click just re-selected the node already on screen, a confusing no-op dressed up as a fresh start. walkIndex now starts at 0 (reflecting "already at the first stop"), the button always reads "Next"/"Done", and the button/variable/id were renamed start-here -> next-button throughout to match what it actually does. - In-body links (<a href="#UUID">) only had hover-preview listeners wired (wireBodyLinkPreviews) -- clicking one fell through to the browser's default same-page fragment-scroll, which had NO visible effect at all since no element in the page actually has that id. Renamed to wireBodyLinks and added a real click handler (preventDefault + selectNode) alongside the existing hover wiring. - Inline code (=x=/~x~ spans) had only a monospace font, nothing to set it apart from surrounding prose. Added a background pill (var(--bg2)), with a companion rule canceling it back to transparent inside <pre> so block code (which already has its own background) doesn't get visibly double-boxed. Three new regression tests. Verified empirically in a real headless browser, not just via string checks: Next genuinely moves to a new node on first click, inline code has a real non-transparent background, and clicking a body link changes the displayed node. Full mae-export suite (88) passes, clippy clean, fmt clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bias The chord nav widget previously rendered in the opposite gruvbox theme from the page with a rounded, drop-shadowed "card" look. In real browser review this read as a boxed-in prototype rather than an integrated part of the page, and violated the dataviz anti-pattern list's guidance against heavy chrome with no breathing room. It now shares the page's own theme variables and surface color, with only a hairline divider separating it from the outline below. Separately, the widget wasn't actually centered in its panel: labels always anchored to the right of their node, and the viewBox's extra label-overflow width was budgeted entirely on the right side too, so the node ring itself sat left-of-center. Labels now flip anchor (start/end) by which half of the ring a node is on -- the standard radial-diagram label convention -- and labelPad is budgeted symmetrically on both sides of the viewBox, so the ring's own bounding box centers correctly regardless of which nodes happen to be labeled. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Batch of real bugs found in browser review of the live guide: - Table cells went through html_escape() alone, never the shared inline- markup converter every other block type uses -- =verbatim=, *bold*, and [[id:...]] links inside a table cell all rendered as literal unconverted org syntax. Affected every table in the KB, not just one note. - plain_text_preview had no match arm for Table at all, so a node body dominated by a table (e.g. a function-reference cheatsheet) got an empty or near-empty hover-popover preview. - The hover popover always anchored bottom-right of the cursor with no viewport clamping -- a node on the right half of the chord ring (which sits in the right-hand sidebar already) rendered its popover mostly off-screen. Now flips per-axis to whichever side actually has room. - Body links whose target isn't in the current curated export's node set (a real, structurally common case under depth-limited extraction) rendered as normal-looking, normal-colored links that silently did nothing on click -- indistinguishable from a working link until tried. Now unwrapped into plain text instead of a dead-but-styled <a>. - Clicking a link left the hover popover stuck on screen indefinitely (the hovered element's mouseleave doesn't reliably fire when the click handler replaces its own DOM). Popover now explicitly hides on every navigation. - Following links freely through the graph had no way back except the linear reading-order Previous/Next walk. selectNode now pushes real browser history (pushState/popstate), so Back/Forward -- the one navigation UX every reader already knows -- works for any navigation path, chord click or body link alike. - Node titles rendered as 11px in-SVG text with viewBox padding reserved for label overflow (first one-sided -- visibly decentering the ring -- then symmetric but still eating a large fraction of the widget's small on-screen size as dead margin). Titles now show in a real-sized #graph-caption above the ring instead; the viewBox only needs to fit the circles themselves. A first cut at that padding (16 world units) didn't account for the anchor/degree radius bonus, stroke width, or hover-lift scale and clipped edge nodes against the panel boundary; bumped to 40, confirmed clean in both themes via headless Chromium. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rage The "On this page" sidebar heading had no explicit font-weight (relying on the <h3> user-agent default) and used the same muted ink as de-emphasized meta text elsewhere, undercutting it as a real section heading. Now explicitly bold with a less-muted color. Theme choice previously never persisted -- themeIdx was recomputed purely from prefers-color-scheme on every load, and data-theme was only ever set inside the click handler, never on initial load. Now reads/ writes localStorage (try/catch guarded), and applies a stored preference immediately on load rather than only after the first click. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The KB-subgraph -> bilingual interactive HTML export (html_graph.rs) shipped several runtime/UX bugs this session with no test layer able to catch any of them -- every existing test asserted on generated-source substrings, none drove a real browser. Extracted into its own hardened, independently tested project, bilingual-kb-export (path-sibling to this repo; see its kb/adrs/0001-extract-into-standalone-project.org for the full rationale), with a real browser-behavioral test suite (Chromium AND Firefox) that caught a further, previously unknown bug on its first run. kb_export_html.rs's boundary was already exactly right for this (GraphExportNode/GraphExportEdge, pre-rendered EN/ES HTML bodies + positions) -- the swap is a straight import-path change, no logic changes needed here. mae-export's own html_graph.rs is removed outright rather than left as an unreferenced, drifting parallel copy; render_element in html.rs drops back to private now that nothing outside this crate needs it. 631 mae-ai tests and 54 mae-export tests still pass, clippy clean, fmt clean, workspace builds clean. Real export re-verified against the actual RoamNotes KB (21 nodes, 12 translations) and against the exact original bug repro in real Firefox: no errors, no stuck state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ort module Ships the bilingual subgraph HTML export as a real mae module, per bilingual-kb-export's ADR-0002 (kb-graph-view's own thin-wrapper-over- compiled-Rust shape, not a Scheme reimplementation): a new (kb-export-subgraph-html ID PATH [DEPTH] [TRANSLATIONS] [TITLE]) primitive queues onto SharedState.pending_kb_export_requests, drained by apply_kb_mutations into the SAME mae_ai::execute_kb_export_subgraph_html the kb_export_subgraph_html MCP tool already calls. modules/kb-subgraph- export/ wires one convenience keybinding (graph view's "e") on top, mirroring kb-graph-view's own module shape exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… tag filter bilingual-kb-export's GraphExportNode gained a tags field this session (defaults empty in build_export_node, same pattern as is_guidance) to drive its new header tag-filter UI. This bridge is the one place with real tag data, so it's the one place that sets it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the bridge-side half of bilingual-kb-export's ADR-0004 (kb/adrs/0004-guidance-nodes-colophon.org): an optional guidance_ids arg, looked up independently of the BFS seed (may live in a different KB instance) and always included in the export's colophon section regardless of reachability -- e.g. a writing-style standard, or a translation- provenance disclosure. A guidance id that doesn't resolve is reported in the status string, not silently dropped, matching node_cap's own truncation reporting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The manual's own content used markdown conventions (**bold**, backtick-delimited code, triple-backtick fences) in several places, which mae's org parser doesn't recognize -- confirmed via org_export on affected buffers, this renders as literal asterisks/backticks instead of emphasis/code, and in concept-sync-engine's case as unrendered inline src blocks. Converts across 56 files: - **bold** -> *bold* (org's real bold delimiter) - `code` -> ~code~ (org's real inline-code delimiter) - ```lang ... ``` fences -> #+begin_src lang / #+end_src concept-conceal.org's render_markup table cell (which documented all six org emphasis styles at once, inherently ambiguous under this conversion) was hand-rewritten as plain prose instead. See #530.
…kb-export-* options Adds 15 new Editor options (kb_export_default_depth/max_depth, kb_export_default_node_cap/max_node_cap, and the 11 ChordDiagramConfig fields), mirroring the existing kb_graph_* 4-site pattern (Editor struct field + Default, options.rs opt! registration, option_ops.rs get/set arms) exactly. Every option's factory default reproduces this tool's original hardcoded behavior byte-for-byte. Wires them into execute_kb_export_subgraph_html: depth/node_cap now read their default/ceiling from the Editor options instead of fixed literals (4-line, fully backward-compatible change). A new resolve_chord_config() helper builds bilingual-kb-export's ChordDiagramConfig from the 11 kb_export_* options, then lets a single new `chord_config` JSON object argument override any subset per-call -- JSON arg present wins, absent falls back to the option, both absent falls back to the hardcoded default. Found and fixed two real bugs while wiring this up: - editor.kb_export_*'s f32 options cast to ChordDiagramConfig's f64 fields via a raw `as f64`, which is lossy for most decimal literals (1.6_f32 as f64 is 1.600000023841858, not 1.6) -- fixed via a string round-trip (f32_option_to_f64). - resolve_chord_config's u32-field override only checked `.as_u64()`, silently ignoring a JSON value serialized as a float (e.g. from the Scheme alist bridge below) -- fixed with an `.as_f64()` fallback. Extends the kb-export-subgraph-html Scheme primitive (crates/scheme/src/runtime/kb_export.rs) to 3 more optional positional args -- NODE-CAP, GUIDANCE-IDS (a list of string/symbol ids), and CHORD-CONFIG (an alist of string/symbol keys to numeric values) -- without registering a new primitive (extends the existing, already-integrated one instead, consistent with kb/adrs/0006 in bilingual-kb-export: adding a genuinely new primitive from outside mae's own tree has no path today, per issue #521). Retires the 3 hardcoded-personal-path throwaway example binaries under crates/ai/examples/ (never committed) in favor of docs/KB_SUBGRAPH_EXPORT.md (real invocation recipes as Scheme calls, the true production interface per this tool's own AI/human-parity design) and one synthetic, no-real-paths export_demo.rs kept as a contributor smoke test.
Integrates the whole-KB subgraph -> interactive HTML export feature (chord-diagram nav widget, bilingual EN/ES overlay, theming, MCP tool, Scheme primitive, kb-subgraph-export module) onto current origin/main, resolving 113 commits of drift since the feature branch's base. Real conflicts resolved (7 files): - crates/scheme/Cargo.toml: kept origin's newer base64 0.23, added the feature's mae-ai dependency + comment explaining the non-circular edge. - assets/manual/concept-org-mode.org: kept origin's correct heading-scale text (*/**/*** -> 1.5x/1.3x/1.15x); the feature branch's own manual-fix commit had introduced a real bug here (repeated single-asterisk, wrong level mapping). - crates/core/src/options.rs, crates/core/src/editor/mod.rs: both sides added new, non-overlapping option/field blocks at the same insertion point (kb_graph_wedge_* vs kb_export_*) -- concatenated both. - crates/core/src/editor/option_ops.rs: same shape in both the getter and setter match arms, but the `_` catch-all differed (origin's dynamic-value fallback vs. the feature's plain error) -- kept origin's more complete fallback, added the feature's kb_export_* arms above it. - crates/export/src/lib.rs: two doc-comment wording conflicts (same underlying Unicode-decode fix described slightly differently on each side) -- kept the more complete wording. - crates/scheme/src/runtime_tests.rs: two independent test additions (kb_register / kb_export_subgraph_html) landed at the same insertion point -- kept both as separate tests. Also found and fixed (not flagged by git as conflicts, but real integration bugs from the drift): - crates/export/src/lib.rs: origin/main independently added bare-URL auto-linking (`is_bare_url_start`) after this branch diverged, with a match on InlineTarget that never anticipated the feature's new `PlainText` variant -- added the missing arm (plain URL text, no markup, matching the org-link PlainText arm's own contract). - crates/export/src/lib.rs: both sides had independently added the exact same Unicode-decode regression test under the same name, merged silently into a duplicate -- removed the duplicate. Structural judgment call: the feature's actual HTML/chord-diagram generation logic (crates/export/src/html_graph.rs, ~6200 lines) had been extracted out of this workspace into a standalone sibling project (bilingual-kb-export, path-dependency of mae-ai) partway through the original branch's development. Folded it back in-tree here so this ships as a normal, self-contained upstream module -- no path-dependency on a sibling checkout that wouldn't exist for other contributors or CI. The Scheme primitive registration itself stays in-tree (crates/scheme/src/runtime/kb_export.rs), per #521's extension point being for genuinely out-of-tree/downstream primitives, not this feature. `cargo build --workspace` and `--features gui` both clean, no warnings. cargo test across mae-core/mae-export/mae-ai/mae-scheme/mae-scheme-extra/ mae (touched crates): all passing, including html_graph.rs's own ported test suite (adversarial script-injection escaping, wedge geometry, config override tests) and the merged kb_export_subgraph_html Scheme tests. cargo clippy -D warnings clean across all touched crates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nvention crates/export/src/html_graph.rs (vendored in-tree during the feat/subgraph-html-export integration) is ~7.75x the 800-line source ceiling. Cross-link it across all three required places per CLAUDE.md's "Debt/Invariant Tagging" section: an @ai-caution: [architecture-debt] marker in the file itself, ROADMAP.md's "Architecture Debt" checklist, and mae-audit.md's "Known exceptions" list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…overage The classify_kb_tool match arm for kb_export_subgraph_html shipped with the feature's original commit, but the three adversarial tests proving it (seed-content-allowed, non-seed-denied, local-provider-always-allowed) only ever existed on a same-day main-only attempt to wire this tool in via the extra-kernel-crates extension point (dd0f9c4/5b51974e), which was fully reverted (ab53312/d495e60a) — for unrelated reasons (that attempt depended on an unshippable external sibling-checkout chain, not because the residency fix itself was wrong. This commit's own history inherits the classification from the feature branch, not that attempt). Ports the tests forward and documents the guidance_ids scoped-out gap inline, matching the reverted commit's more thorough analysis.
… recipes The merged feature branch's doc included a real anchor id, real file paths, and a real note title from an actual RoamNotes export in this session. That KB's content must never leave this machine or land in this repo (standing rule) -- this is about to become a public GitHub PR, so swap the real example for a synthetic one demonstrating the same translations/title-override arguments.
strip_leading_properties_drawer only stripped a :PROPERTIES:...:END:
drawer when nothing but whitespace came before it in the body. Real,
reproducible leak in the onprem-iac KB export: a roadmap-step list
item's own drawer (org only auto-hides drawers that immediately follow
a HEADLINE, not a plain list item -- the same gotcha org-kb-to-pdf
documents for the LaTeX pipeline) kept the item's own leading prose
line ("upgrade fails partway.") ahead of the drawer, so the whole
:PROPERTIES:/:ID:/:END: block rendered as visible text.
Now splices the drawer out wherever it appears within the existing
500-char bound, as long as it's alone on its own line (real org drawer
syntax) -- preserves real leading prose instead of requiring the body
to have none, while a guard test confirms a body that merely mentions
":PROPERTIES:" mid-sentence is left untouched.
…content The org parser already had a top-level handler skipping a :PROPERTIES: ...:END: drawer at the start of a document/paragraph (#528), but it only ran in the main line-scanning loop. Once the parser entered a list item's continuation-line accumulation (both ordered and unordered), every non-blank line -- including a nested drawer's own :PROPERTIES:/ :ID:/:END: lines -- got glued into the item's rendered content unconditionally. Real, reproducible leak: the onprem-iac KB gives individual roadmap/ checklist steps their own stable :ID:, so a numbered list routinely has one drawer per item, not just one for the whole document. Extracted the skip-to-:END: logic into a shared skip_properties_drawer() helper and call it from both list continuation loops too.
… in paragraphs too Two more real bugs found sweeping the onprem-iac KB export for remaining drawer leaks after the list-loop fix: 1. strip_prefix(|c: char| c.is_ascii_digit()) strips exactly one leading digit, not a run -- "10. Document..." became "0. Document..." after stripping the "1", then failed the ". "/") " check entirely. Any ordered list with 10+ items had its 10th+ items silently fall through to plain-paragraph parsing instead of being recognized as list items. Replaced with strip_ordered_marker(), which strips the whole digit run. 2. That fallthrough exposed a third, unfixed accumulation site: the generic paragraph-continuation loop had the same "glue every non-blank line in verbatim" behavior the two list loops had before the previous commit, so a mis-parsed item 10's own :PROPERTIES: drawer leaked there too. Same skip_properties_drawer() fix applied. Swept the real onprem-iac export after both fixes: 222 -> 30 -> 0 remaining :PROPERTIES: occurrences.
convert_inline_markup_str's ~code~/=code= and +strikethrough+ arms
passed `content` straight into `<code>{}</code>`/`<del>{}</del>` with
zero escaping -- unlike the recursively-converted bold/italic arms,
which self-escape via recursion. A KB node body containing
~<img src=x onerror=...>~ produced a live tag in the rendered
fragment, which kb_export_subgraph_html assigns via element.innerHTML
in the exported page's chord-diagram viewer -- a no-click stored XSS
reachable by any party with write access to a node the export's BFS
touches (including another peer in a shared/federated KB).
Found by a security review before merging PR #567.
OrgElement::ExportBlock's html.rs render arm passed raw HTML through verbatim -- standard org semantics for a trusted single author, but this parser also feeds kb_export_subgraph_html's render path, which exports content that isn't necessarily self-authored (a federated/ shared KB). A KB node body containing #+begin_export html with a crafted <img onerror=...> produced a live, no-click stored XSS in the exported page, same delivery path as the inline-code-span fix. Adds ExportOptions::allow_raw_html_export_blocks (default false, deliberately NOT settable via the document's own #+OPTIONS: line, since that would let the same untrusted content re-enable itself) and a matching editor option org_export_allow_raw_html_blocks (also default false) so org_export -- which exports the human's own local buffer -- can still opt into standard raw-passthrough semantics. kb_export_subgraph_html never reads this option; that path stays hardcoded-safe by construction, since a shareable multi-party export should never trust embedded raw HTML. Found by a security review before merging PR #567.
… loss) skip_properties_drawer (now skip_drawer) and its three call sites only matched the literal token ":properties:". Real, reproducible bug worse than a metadata leak: a :LOGBOOK: drawer (auto-inserted by stock Emacs on every TODO-state change / clock entry when org-log-into-drawer is set -- extremely common in real org files) fell straight through to list/paragraph parsing, leaking its own :END: line as visible text AND swallowing real body prose that followed it into the wrong element. Generalized to is_drawer_open_line(): any ":NAME:" alone on its own line (org's real drawer syntax) opens a drawer to skip, with adversarial guards so a bare ":END:" is never mistaken for a drawer literally named "END", and a mid-sentence ":word:" mention in prose is left alone. Found by a correctness review before merging PR #567.
…rocess) kb_export_subgraph_html was registered under PermissionTier::Write, but its mermaid-diagram pre-rendering (try_render_mermaid_svg) shells out to `npx @mermaid-js/mermaid-cli` -- real subprocess execution with potential network access (npx fetches the package if not cached). babel_execute, babel_tangle, and org_export already gate the same class of operation behind Shell; this tool was inconsistent with that established convention. Found by a security review before merging PR #567.
Each guidance_ids entry resolves independently of the seed, possibly
into a DIFFERENT KB instance than the one the seed's own dispatch-time
residency check (SingleTarget) covers -- an agent that knows/guesses a
node id living in a residency-restricted KB could pull its full
content into the export's colophon via a permitted seed elsewhere.
This was a real, previously-documented gap (ai_residency.rs), not
hypothetical, and had no tracking issue.
execute_kb_export_subgraph_html now takes requester_provider and
post-filters its resolved guidance nodes via
mae_core::ai_residency::filter_residency_exempt_by -- the same
primitive kb_links_from's own per-target residency check already
uses (reusing links_backend/describe_for_filter from kb.rs, now
pub(crate), rather than reimplementing instance resolution). Anything
denied is explicitly reported in the tool's returned status
("N guidance id(s) omitted (residency-restricted)"), matching
missing_guidance_ids' existing never-silent convention.
Non-MCP call sites (the Scheme primitive bridge, the :kb-export-html
colon command) pass requester_provider: None, the safe conservative
default -- the residency filter only skips for a CONFIRMED local
provider, so an unknown caller still gets filtered, never bypassed.
Found by a security review before merging PR #567.
html_graph.rs embedded ~900 lines of CSS and ~1,700 lines of hand- written JavaScript as raw Rust string literals -- over 40% of the file's line count was front-end asset source, not Rust logic, un-lintable and un-syntax-checkable as real JS/CSS. This already let a real bug through (a regex literal corrupted by the inline-script escaper, caught only by manually running `node --check` -- not by any CI step, since there wasn't one for an inline string literal). Moved to crates/export/assets/graph.js and graph.css, loaded via include_str!() -- the established MAE convention for exactly this (crates/core/src/theme.rs's theme TOMLs, crates/mae/src/bootstrap.rs's agent-prompt XML, etc.), already suggested as the fix in this file's own doc comment. Shrinks html_graph.rs from ~6,270 to ~3,700 lines; content is unchanged (all 197 mae-export tests still pass unmodified). Enables `node --check` and a real browser-execution test suite as follow-up commits in this same pass. Found by an architecture review before merging PR #567.
…SON injection
render_graph_js/render_static_css applied ChordDiagramConfig to the
generated page via str::replacen against literal anchor text (e.g.
"var HOVER_GROWTH_FACTOR = 1.6;") -- silently a no-op (dead option, no
error signal) if that literal text ever reformatted, since it depended
on an exact byte-for-byte match against the JS/CSS source.
The 10 JS-facing fields now flow through the #graph-data JSON payload
(already used for node/edge data) as a real chordConfig object;
graph.js reads it once at load with `??` (not `||`, since 0 is a real,
documented value for edge_pull_back/wedge_gap_radians, not "unset")
against hardcoded defaults matching ChordDiagramConfig::default()
exactly -- so the file stays independently valid, node --check-able JS
even without that payload. The remaining CSS-facing field
(ui_transition_ms) becomes a real CSS custom property
(--ui-transition-ms, with a 200ms fallback on every var() use in
graph.css) set via one small :root{} rule instead of literal-text
substitution -- the 180ms/220ms deliberately-fixed exceptions are
untouched, same as before.
Deletes render_graph_js and its 12-arm replacen chain entirely.
Rewrote the tests that asserted on generated-JS *source text* to
assert on the resolved chordConfig payload instead (the actual
mechanism now), and added an adversarial case the old mechanism had no
equivalent gap for: edge_pull_back: 0.0 must not be mistaken for
"unset" and silently replaced with the 0.55 default.
Found by an architecture review before merging PR #567.
…-export-subgraph-html crates/export/src/html_graph.rs's own test suite only asserts on generated HTML/JS *source text* -- none of it loads the exported page and drives it, which is exactly how a real bug (a regex literal corrupted by the inline-script escaper) shipped unnoticed and was caught only by manually running `node --check`, not by any automated gate. This "Layer 2" suite existed in the standalone project this feature was folded back in-tree from, but wasn't carried over during that integration. Ports the harness (plain `node --test` + `puppeteer-core` driving real Chromium AND Firefox over file://, no headless-browser bundling or DOM mocking) and its fixture generator (crates/export/examples/ fixture_export.rs, using mae-export's own public html_graph API directly -- no Editor/KB dependency needed) verbatim: all 54 tests (27 x 2 engines) pass unmodified against the ported fixture, including a real runtime confirmation that a ChordDiagramConfig override (hover_growth_factor) produces measurably different behavior -- not just different generated source text, validating the prior commit's JSON-injection config mechanism end to end. Own package.json/package-lock.json/gitignored node_modules, not wired into `make test`/CI yet (see the new README.md) -- run manually before merging any change to crates/export/assets/ or html_graph.rs's HTML assembly. Found by an architecture review before merging PR #567.
Adds an export-js path filter + a minimal export-js-check job that runs `node --check crates/export/assets/graph.js` on any change to that file -- a cheap, permanent version of the manual check that was the ONLY thing catching a real regex-literal-corruption bug in this file before it became a real checked-in asset (see the prior two commits). Distinct from and much faster than the full real-browser suite in crates/export/tests/browser/, which isn't wired into CI yet. Also updates ROADMAP.md's and mae-audit.md's html_graph.rs entries to reflect the completed JS/CSS extraction (~6,205 -> ~3,700 lines) -- per CLAUDE.md's cross-reference discipline, these two docs and the file's own @ai-caution marker should all read consistently.
resolve_chord_config accepted any f64/u32 in a chord_config override unvalidated, unlike depth/node_cap one field over (each backed by a real Editor option ceiling). A wildly out-of-range value (e.g. edge_pull_back: 1e300) flowed straight into generated JS with zero server-side sanity check, producing garbage SVG geometry client-side with no error signal. Each field now clamps to a generous, doc-justified range (e.g. edge_pull_back/wedge_corner_radius_fraction to their own documented [0, 1] meaning). NaN/Infinity are deliberately NOT special-cased -- serde_json::Value structurally cannot represent a non-finite f64 at all (confirmed empirically: Number::from_f64 rejects it at construction in both the JSON-RPC wire format and the Scheme- primitive-to-JSON bridge), so a .is_finite() guard would be dead code testing a scenario the type system already makes impossible. The real, reachable adversarial case is a legitimately finite NEGATIVE float for a u32 field, which override_u32! now rejects outright (kept at the prior/default value) rather than letting a saturating float-to-int cast silently reinterpret e.g. -5.0 as 0. Found by a correctness review before merging PR #567.
ListItem::children existed since this parser's origin but was never populated -- an indented sub-list silently flattened into its parent list with no structural signal a nested list ever existed, a real, reproducible silent-content-downgrade found by a correctness review. parse_list_items() is a new shared, recursive helper (replacing the duplicated inline while-loops in the unordered/ordered list branches): a later line matching a list-item marker at strictly greater indentation than the current level's own marker column starts a nested list under the last item; same indentation is a sibling; lesser indentation closes the level back to the caller. Handles any real nesting depth for the same complexity as a hard-coded single level. Composes correctly with the drawer-generalization fix from earlier in this pass (a nested item's own drawer doesn't leak either). Parsing alone isn't the whole fix -- wired real recursive rendering into all three consumers so nested content doesn't silently vanish downstream: html.rs (nested <ul>/<ol>), markdown.rs and org_writer.rs (2-space-per-level indentation, standard convention for both formats), and html_graph.rs's plain_text_preview (flattens nested text into the hover-popover string, matching its existing flatten-everything behavior). parse_reading_order/parse_reading_order_part deliberately NOT changed -- that's a narrow, flat-only project convention (a `* Reading Order` section's Previous/Next/Part list is never meant to nest), not a gap. A plain, consistently-indented flat list -- the overwhelmingly common case, and every list fixture in this crate's test suite before this feature -- produces the exact same structure as before: all 199 pre-existing tests pass unmodified. Found by a correctness review before merging PR #567.
std::fs::write(&out_path, &html) was a single non-atomic write -- a disk-full/kill/power-loss mid-write could leave a partial/corrupt HTML file at the destination path, with no way for a caller to distinguish that from a genuinely complete export. write_atomically() now writes to a `<name>.tmp-<pid>` file in the SAME directory (guaranteeing the final rename is same-filesystem, never a cross-filesystem copy that could itself fail partway), then renames it over the destination -- the destination can now only ever be observed fully-old or fully-new, never partial. Best-effort temp-file cleanup on a write failure. No new dependency (tempfile is already a dev-dependency only, not available to production code). Found by a correctness review before merging PR #567.
Ports the 4 still-relevant design decisions from the feature's original standalone-project history into real numbered MAE ADRs (077-081; 080 is superseded by 081, which documents the JSON-injection config mechanism that replaced it in Phase 3). ADRs describing the now-obsolete standalone-sibling-project architecture (extraction, clone-dependency) are deliberately not ported, per the decision already made when this feature was folded in-tree. Bulk-replaces every dangling `kb/adrs/NNNN`/`bilingual-kb-export` comment citation across the crate with a reference to the new ADRs, or drops the citation where the source ADR wasn't ported. Also fixes one scheme-level test (`kb_export_subgraph_html_from_scheme_with_chord_config_alist_override`) still asserting against generated JS source text instead of the real JSON config-injection payload -- missed by Phase 3.2's rewrite since it lives in `crates/scheme`, not `crates/export`/`crates/ai`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
crates/export/src/html_graph.rs's GruvboxPalette is a hand-copied
byte-for-byte snapshot of crates/core/src/themes/gruvbox-{dark,light}.toml,
needed so the exported HTML page stays self-contained/offline with no
runtime dependency on the editor's theme system. Nothing currently
catches the two drifting apart if either TOML file changes.
Not fixed in this PR -- filed as #568 and cross-linked
from an @ai-caution: [architecture-debt] marker on the struct plus a
new ROADMAP.md Architecture Debt entry, per the project's 3-way debt
cross-reference discipline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pre-merge test-quality review flagged simple_node("a", "A", ...)
as a repeated unicorn-value fixture across the html_graph.rs test
suite -- a value chosen for convenience that could incidentally dodge
an edge case a more realistic id/title would hit. Diversifies the ids
and titles specifically in the tests most tied to this PR's Phase 1
security fixes (script/style tag breakout, #+begin_export html
escaping, quote/JSON escaping, PROPERTIES-drawer leak) rather than a
blanket rewrite of the ~30 other tests still using the shared fixture
for unrelated behavior. The adversarial payloads themselves are
unchanged -- only the surrounding id/title context varies.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kb_export_subgraph_html's depth/node_cap resolution (.and_then(|v| v.as_u64())) silently returns None for a JSON string or a negative number, falling through to the Editor-option default -- correct behavior, but previously untested per the pre-merge review. Adds a test with depth="many" and node_cap=-5 confirming the call succeeds and produces the expected default-depth/default-node_cap export (root + 3 spokes, nothing hidden) instead of panicking or silently truncating to zero nodes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
daemon/Cargo.lock's mae-daemon/mae-kb/mae-mcp/mae-sync entries were still pinned at 0.14.81, stale relative to the editor workspace's current 0.14.87 -- picked up by this PR's final-verification daemon build (daemon has its own separate Cargo.lock per ADR-014). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This checkout never ran `make setup-hooks`, so every commit in this session's hardening pass skipped the local fmt/clippy/code-map/ADR pre-commit gate entirely, letting two purely mechanical issues reach CI: unformatted manual edits across 5 files, and a code map that hadn't been regenerated after this PR's file moves/additions. Also enables the hooks for this checkout going forward (`make setup-hooks`) so this doesn't recur. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion gap Two related fixes found while validating kb-export-subgraph-html against a real personal KB after PR #567's hardening pass: 1. required_tag (ADR-082): kb_export_subgraph_html previously only offered seed+depth BFS with client-side tag dimming, so export correctness depended on picking exactly the right seed/depth by luck. A real case (exporting a curated "terraform onboarding" walkthrough) picked a plausible but wrong seed and pulled in 100+ unrelated reference nodes with no error. New optional `required_tag` param (MCP tool + Scheme primitive, mae_kb::SubgraphSpec) hard-filters the final node set to only tagged nodes (seed always included) while BFS still traverses through untagged intermediates -- applied before node_cap so the two cutoffs compose correctly and reuse the existing boundary-link demotion path. Reports how many nodes were excluded (tag_filtered_count), same never-silent-truncation convention as node_cap. 2. kb_agenda federation gap (ADR-083): while investigating why kb_agenda(filter=tag) returned zero results against a real federated RoamNotes instance despite the node being correctly tagged, traced execute_kb_agenda to only ever querying editor.kb.store (the primary KB's own Cozo store) -- every filter type was structurally unable to see ANY federated instance's nodes, not a tag-matching bug at all. Fixed by mirroring execute_kb_health's per-instance registry-iteration pattern: queries each in-scope instance's CozoKbStore when open, or a new KnowledgeBase::agenda_query_in_memory fallback (field-for-field matched against the Cozo query semantics) for a purely in-memory federated instance. Stale/Custom have no in-memory equivalent and are now reported via skipped_instances rather than silently omitted. Reclassified kb_agenda from PrimaryOnlyFilterable to ScopedFederatedScanFilterable (crates/mae/src/ai_residency.rs) since that shape's contract now genuinely matches kb_search's; the old PrimaryOnlyFilterable variant is removed entirely (zero real tools left in it) rather than kept as dead code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the
kb-export-subgraph-htmlprimitive upstream: exports a KB subgraph (seed node + BFS neighborhood) to one self-contained, offline-capable HTML file with a chord-diagram nav sidebar, per-node outline, search, tag filtering, and an optional bilingual (EN/ES) overlay. Ships as(kb-export-subgraph-html id path [depth] [translations] [title] [node-cap] [guidance-ids] [chord-config])in Scheme, akb_export_subgraph_htmlMCP tool, andkb_export_*persistent options for depth/node-cap defaults and the chord diagram's layout/timing constants.This work already existed on an untracked local branch (
feat/subgraph-html-export, 19 commits, based on an oldmain). This PR merges it forward onto currentmain(113 commits of drift) and fixes what the merge + real-world testing surfaced.Why not the existing extension-point wiring
mainbriefly had this feature wired in via theextra-kernel-cratesextension point (#521) as an out-of-tree dependency on a sibling checkout (dd0f9c47), then fully reverted the same day (ab53312d). That sibling project itself depended on another sibling checkout via a relative path — an unshippable, personal-machine-only dependency chain that would never build for another contributor or CI. This PR instead vendors the export code in-tree (crates/export/src/html_graph.rs), consistent with why that attempt was reverted (see ADR-077). The Scheme primitive stays in-tree too (crates/scheme/src/runtime/kb_export.rs) — the extension point is for genuinely out-of-tree/downstream primitives, not this one.Bugs found and fixed via real-world testing (initial pass)
Verified end-to-end by exporting a real 245-node production KB (an infrastructure-as-code migration project), not just fixture data. Found and fixed three real org-parser bugs along the way, none specific to this feature — all in the shared
crates/exportorg parser also used by plainorg_export:Pre-merge hardening pass (multi-lens review)
Before merging, this PR went through a deep multi-lens review (architecture, security, correctness, test-quality — 4 independent passes against CLAUDE.md's design principles) and a full remediation pass through every finding, phase by phase:
Phase 1 — Critical security fixes
~code~/=code=→<code>,+strike+→<del>): content is now HTML-escaped before wrapping.#+begin_export html: previously passed raw HTML through unconditionally — the single shared render site used by bothorg_exportand this feature'srender_node_body_html. Now escapes by default; a neworg_export_allow_raw_html_blocksoption (default off, Scheme/:set-accessible per principle feat: Phase 4a M5 — async LSP AI tools #7) lets a trusted single-authororg_exportcall opt back into standard org semantics.kb_export_subgraph_htmlnever opts in — it exports content that isn't necessarily self-authored.:PROPERTIES:token; any other drawer (most commonly Emacs's auto-inserted:LOGBOOK:) leaked its raw lines as visible prose and could swallow real body text that followed. Generalized drawer-open detection to any:name:-alone-on-its-line shape, applied consistently across all four places the parser skips drawers.kb_export_subgraph_htmlshells out tonpx @mermaid-js/mermaid-clibut was registered atWritetier. Bumped toShell, matching every other subprocess-executing tool (babel_execute,org_export,shell_exec).Phase 2 — Residency gap fix
guidance_idsbypassed AI-residency gating (ADR-048) — each id now gets the samecheck_kb_residencygatekb_getitself uses; omitted ids are reported explicitly in the tool's status message, never silently dropped.Phase 3 — Architecture: real assets, real config injection, real JS tests
crates/export/assets/graph.js/graph.cssfiles (include_str!, matching MAE's established asset convention) — shrinkshtml_graph.rsfrom ~6,205 to ~3,700 lines.replacen-against-exact-JS-text config patching (silently dead if the JS text ever changed shape) with real JSON injection:ChordDiagramConfigvalues flow through the same#graph-dataJSON payload the node/edge data already uses.crates/export/tests/browser/, 54 tests,node --test+puppeteer-coredriving real Chromium + Firefox) — this is exactly the class of bug (a regex literal corrupted by the inline-script escaper) that source-text-only Rust assertions had already let through once, caught only by manually runningnode --check.node --checkCI gate (export-js-checkjob) ongraph.jsas a cheap syntax check independent of the full browser suite.Phase 4 — Correctness fixes
chord_config's numeric overrides to sane ranges (previously accepted anyf64/negative unvalidated).ListItem::children, previously declared but never populated) — indentation-based, one real level of nesting.Phase 5 — Documentation/drift cleanup
GruvboxPalettehand-copied theme-snapshot drift risk (no automated check that it stays in sync withcrates/core/src/themes/gruvbox-*.toml), cross-linked from an@ai-caution: [architecture-debt]marker + aROADMAP.mdentry.Phase 6 — Test-quality improvements
simple_node("a", "A", ...)) in the tests most tied to the Phase 1 security fixes.depth/node_capJSON values falling back cleanly to Editor-option defaults instead of panicking or silently misinterpreting.Test plan
cargo build --workspace --features gui: cleancargo test --workspace --features gui(full workspace, daemon built separately per ADR-014's two-workspace split): all greencargo clippy --workspace --features gui -- -D warnings: cleancrates/export/tests/browser/,npm test): 54/54 passing across Chromium + Firefoxnode --check crates/export/assets/graph.js: clean:PROPERTIES:/:LOGBOOK:leaks~<img src=x onerror=...>~and a#+begin_export htmlblock with a live<script>) against a throwaway KB node, exported, confirmed both render as inert escaped text — traced the one raw-looking match in the JSON payload'spreview_enfield to confirm it's only ever consumed via.textContentingraph.js, neverinnerHTML(onlybody_en, the HTML-escaped field, reachesinnerHTML)Follow-up:
required_tagexport filter +kb_agendafederation fixA second real-world validation round against two live personal KBs (an onprem-iac migration KB and
a RoamNotes-style federated KB) surfaced two more gaps, both fixed on this branch:
required_tag— a hard tag filter, independent ofnode_cap(ADR-082). Exporting a curated"terraform onboarding" walkthrough picked a plausible-looking but wrong seed (a general reference
hub instead of the actual guided-walkthrough node), pulling in 100+ unrelated reference nodes with
no error — export correctness depended entirely on picking the right seed+depth by luck, even
though the KB's nodes were already correctly tagged. New optional
required_tagparam (MCP tool +Scheme primitive +
mae_kb::SubgraphSpec) hard-filters the final exported node set to only nodescarrying that tag (the seed is always kept, regardless of its own tags) — BFS still traverses
through untagged intermediates to reach tagged nodes beyond them, applied before
node_capso thetwo cutoffs compose correctly and reuse the existing boundary-link-demotion path with zero new
link-handling code. Reports how many nodes were excluded (
tag_filtered_count), samenever-silent-truncation convention as
node_cap.kb_agendanever actually queried federated KBs (ADR-083). While diagnosing whykb_agenda(filter=tag)returned zero results against a real federated instance despite the nodebeing correctly tagged, traced
execute_kb_agendato only ever readingeditor.kb.store(thePRIMARY KB's own Cozo store) — every filter type (
todo/priority/tag/stale/orphan/dead_end/missing_role/weakly_linked/custom) was structurally unable to see ANY federatedinstance's content, not a tag-matching bug at all. Fixed by mirroring
execute_kb_health'sper-instance registry-iteration pattern: queries each in-scope instance's
CozoKbStorewhen one isopen, or a new
KnowledgeBase::agenda_query_in_memoryfallback (matched field-for-field againstthe Cozo query semantics, including its substring tag-matching convention — deliberately NOT
nodes_by_tag's exact-match convention, so results stay identical regardless of which backend agiven instance happens to have) for a purely in-memory federated instance.
Stale/Customhave noin-memory equivalent (no per-node last-modified timestamp exists without a Cozo store) and are now
reported via a new
skipped_instancesfield rather than silently omitted. Reclassified fromPrimaryOnlyFilterabletoScopedFederatedScanFilterablein the AI-residency gate(
crates/mae/src/ai_residency.rs) — the old shape had zero real tools left in it oncekb_agendamoved, so it's removed entirely rather than kept as dead code.
Also fixed: this checkout had never run
make setup-hooks, so the local pre-commit fmt/clippy/code-map/ADR-staleness gate was silently skipped for the whole session — caught via real CI
failures (
stable / fmt,code-map freshness) on the first push, root-caused, and fixed (hooksnow enabled for this checkout going forward).
Re-verified with the same rigor as the original pass: full
cargo build/cargo test --workspace --features gui/cargo clippy -- -D warningsclean,cargo fmt --checkclean,make code-map-checkclean, daemon workspace
cargo build/cargo testclean, plus new adversarial + real-KB-shaped unittests for both fixes (
shared/kb/src/lib.rs'sextract_subgraph_required_tag_*/agenda_query_in_memory_*,crates/ai/src/tool_impls/kb.rs'skb_agenda_finds_a_tagged_node_in_a_ purely_in_memory_federated_instance/kb_agenda_stale_filter_reports_an_in_memory_only_instance_as_ skipped).🤖 Generated with Claude Code