diff --git a/Cargo.lock b/Cargo.lock
index f152980..62a1281 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -236,6 +236,21 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "assert_cmd"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
@@ -375,6 +390,17 @@ dependencies = [
"alloc-stdlib",
]
+[[package]]
+name = "bstr"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde_core",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -892,6 +918,12 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -1051,6 +1083,21 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+[[package]]
+name = "figmog"
+version = "0.0.0"
+dependencies = [
+ "assert_cmd",
+ "clap",
+ "fold",
+ "postcard",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+ "ureq",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2323,6 +2370,33 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "predicates"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "predicates-core",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -2956,6 +3030,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3364,6 +3444,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "walkdir"
version = "2.5.0"
diff --git a/README.md b/README.md
index 951a620..79924f8 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,10 @@ In this directory you'll find a few examples that show bog style databases in va
- `timeseries` — weather readings bucketed into hourly and daily aggregates, updated incrementally. `cargo run -p timeseries`
- `chat` — a chat backend where fold is the source of truth and every update is broadcast to clients over a websocket. `cargo run -p chat`, then open http://localhost:3000
- `search` — text search three ways over one document stream: BM25 keyword search, HNSW semantic search over ese embeddings, and hybrid rank fusion. A good base for agent memory or document search projects. `cargo run -p search`
+- `figmog` — a local mirror of a Figma file: sync once, then search, walk, and
+ query components/styles/variables with zero API calls, and a multi-file MCP
+ server (`figmog serve`), with a built-in load-test demo (`figmog bench`).
+ `cargo run -p figmog -- --help`
## More about Bog
Bog is a database runtime that makes every attempt to do as much work as possible as early as possible, to make reads incredibly fast. This means compiling queries into functions that eagerly update their output as mutations occur.
diff --git a/docs/superpowers/plans/2026-08-15-figmog-serve.md b/docs/superpowers/plans/2026-08-15-figmog-serve.md
new file mode 100644
index 0000000..2e6e264
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-figmog-serve.md
@@ -0,0 +1,207 @@
+# figmog serve (MCP server) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `figmog serve` — an MCP stdio server over the figmog store with integrated background sync, so agents query the mirror through MCP tools with always-fresh data.
+
+**Architecture:** Spec §11 of `docs/superpowers/specs/2026-08-15-figmog-build-design.md` (read it first — it is the binding authority). Three moves: extract the CLI's read logic into a shared `query` module returning JSON; implement a dependency-free JSON-RPC/MCP protocol core; run one main loop that owns the store, answering requests between poll ticks.
+
+**Tech Stack:** No new dependencies. JSON-RPC hand-rolled over `serde_json`; threads + `std::sync::mpsc` for the stdin reader; `std::process` in tests.
+
+**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md (§11)
+
+## Global Constraints
+
+- Zero new crates in Cargo.toml. Stdout in serve mode carries ONLY newline-delimited JSON-RPC frames; all logging to stderr.
+- All v1 behavior unchanged: every existing test keeps passing without modification (except moves of test-internal imports if a type relocates). The CLI's human/JSON output is byte-identical.
+- Determinism rules hold (sorted outputs, no HashMap iteration at boundaries).
+- Gates per task: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check` all clean. Zero diff to fold/ese/anny.
+- If `cargo` is not on PATH, prefix commands with `export PATH="$HOME/.cargo/bin:$PATH" && `.
+- Commits end with the `Co-Authored-By: Claude Fable 5 ` trailer.
+
+---
+
+### Task 1: `query.rs` — extract read logic from the CLI
+
+**Files:**
+- Create: `examples/figmog/src/query.rs`
+- Modify: `examples/figmog/src/cli.rs`, `examples/figmog/src/lib.rs` (add `pub mod query;`)
+- Test: existing `tests/cli.rs` must pass unchanged (that IS the test of this refactor)
+
+**Interfaces:**
+- Produces `pub fn`s in `figmog::query`, each taking the concrete reader types it needs (same signatures style as today's `cmd_*` — generic over `R: Readable`) and returning `Result`:
+ - `status(nodes, meta) -> Result` — the object today's `cmd_status` builds
+ - `pages(nodes, by_type) -> Result` — JSON array
+ - `tree(nodes, children, by_type, id: Option, depth: Option) -> Result` — move `TreeNode`, `build_tree`, `tree_to_json` here; `TreeNode` and `build_tree` stay `pub` so the CLI's human printer can render from the same structure: also expose `pub fn tree_nodes(...) -> Result` and have `tree()` wrap it
+ - `node(nodes, children, id: String, with_children: bool) -> Result`
+ - `find(nodes, by_type, node_type: String, page: Option) -> Result`
+ - `search(text, nodes, query: &str, limit: usize) -> Result`
+ - `instances(nodes, components, component_sets, instances_of, target: &str) -> Result` (move `resolve_component_ids` here as a private fn)
+ - `components(nodes, components, component_sets) -> Result`
+ - `styles(nodes, styles, styled_by, style_type: Option, values: bool) -> Result`
+ - `uses(nodes, styled_by, bound_to, id: &str) -> Result`
+ - `vars(nodes, variables, variable_collections, id_filter: Option) -> Result`
+- Consumed by: cli.rs `cmd_*` (become printers: call `query::*`, then either `println!("{}", serde_json::to_string(...))` in json mode or render human lines from the returned Value/TreeNode exactly as today), and by Task 3's tool handler.
+
+- [ ] **Step 1:** Create `query.rs` by MOVING the body logic of each `cmd_*` read function (everything between reader access and printing) plus `TreeNode`/`build_tree`/`tree_to_json`/`resolve_component_ids` out of `cli.rs`. The JSON each function returns is exactly the Value the old code printed in `--json` mode. Human rendering stays in `cli.rs`, rebuilt from the returned Value (or `TreeNode` for tree). Node-id normalization (`normalize_node_id`) stays at the CLI/tool boundary — `query::*` receives already-normalized ids EXCEPT where today's code normalizes internally; preserve today's exact behavior.
+- [ ] **Step 2:** Rewrite each `cmd_*` in `cli.rs` as a thin printer over `query::*`. Doc-comment `query.rs` (module: "One source of truth for every read answer — shared by the CLI printers and the MCP tools.").
+- [ ] **Step 3:** Run the full suite: `cargo test -p figmog`. Every existing test must pass WITHOUT edits — if a cli test fails, the refactor changed behavior; fix the refactor, not the test. Then clippy + fmt gates.
+- [ ] **Step 4:** Commit: `refactor(figmog): extract query layer shared by CLI and MCP`
+
+---
+
+### Task 2: `mcp.rs` — protocol core
+
+**Files:**
+- Create: `examples/figmog/src/mcp.rs`
+- Modify: `examples/figmog/src/lib.rs` (add `pub mod mcp;`)
+- Test: unit tests in `mcp.rs`
+
+**Interfaces:**
+- Produces:
+ ```rust
+ /// One registered tool: metadata for tools/list.
+ pub struct ToolDef {
+ pub name: &'static str,
+ pub description: &'static str,
+ /// JSON Schema for the tool's arguments.
+ pub input_schema: serde_json::Value,
+ }
+ /// Executes a tools/call. Ok(v) => success content; Err(msg) => isError content.
+ pub trait ToolHandler {
+ fn call(&mut self, name: &str, args: &serde_json::Value) -> Result;
+ }
+ /// Handle one incoming JSON-RPC message. Returns the response frame to
+ /// write, or None for notifications (and for malformed input handled
+ /// via the returned parse-error frame — see below).
+ pub fn handle_message(
+ raw: &str,
+ tools: &[ToolDef],
+ handler: &mut dyn ToolHandler,
+ ) -> Option;
+ pub const SERVER_NAME: &str = "figmog";
+ ```
+- Behavior contract (unit-test each):
+ - Parse failure → `Some({jsonrpc:"2.0", id: null, error:{code:-32700, message:"parse error"}})`.
+ - `initialize` → result `{protocolVersion: , capabilities: {tools: {}}, serverInfo: {name: "figmog", version: env!("CARGO_PKG_VERSION")}, instructions: }`.
+ - `notifications/initialized` (and any method starting `notifications/`) → `None`.
+ - `ping` → result `{}`.
+ - `tools/list` → `{tools: [{name, description, inputSchema}...]}` from the `ToolDef` slice, in slice order. (The protocol core is registry-agnostic; the real 17-tool registry arrives in Task 4.)
+ - `tools/call` with `{name, arguments}` → invoke handler; Ok(v) → result `{content: [{type:"text", text: serde_json::to_string(&v)}], isError: false}`; Err(msg) → result `{content:[{type:"text", text: msg}], isError: true}`. Unknown tool name → handler returns Err (Task 3 handler) — but `mcp.rs` itself must also map a `name` missing from `tools` to the same isError shape without calling the handler.
+ - Any other method with an `id` → error `-32601` "method not found". Requests without `id` (notifications) → `None`.
+- [ ] **Step 1:** Write the failing unit tests for every bullet above (scripted `&str` → expected `Value` assertions; a `NullHandler` test double returning `Ok(json!({"ok":true}))` / `Err("boom")` by tool name).
+- [ ] **Step 2:** Verify compile failure, implement, iterate to green. Gates.
+- [ ] **Step 3:** Commit: `feat(figmog): MCP protocol core (JSON-RPC over stdio frames)`
+
+---
+
+### Task 3: structural query pack (query fns + CLI subcommands)
+
+**Files:**
+- Modify: `examples/figmog/src/query.rs`, `examples/figmog/src/cli.rs`
+- Test: extend `examples/figmog/tests/cli.rs`
+
+**Interfaces:**
+- Consumes: Task 1's `query.rs` layout and reader-type conventions.
+- Produces five new `query::*` functions (same `Result` convention) and five CLI subcommands, per spec §11's "whole-file structural queries" table (the spec table is the authority for inputs/outputs):
+ - `query::stats(nodes, components, component_sets, styles, variables, by_type)` — counts by node type (from iterating nodes, sorted by type name), counts per page (page_id → n, sorted), table totals, text-node count, max depth (walk parent chains or recurse via children — iterate nodes computing depth by following `parent_id` with memoization-free repeated walks; the file is local, O(n·depth) is fine).
+ - `query::path(nodes, id)` — follow `parent_id` to the root, then reverse: `[{id, name, type}]` root-first. Unknown id → Err.
+ - `query::text(nodes, by_type, page: Option)` — `by_type.search("TEXT")`, look up, optional page filter, sorted by id, `[{id, characters, page_id}]` (characters from `NodeRec.text`).
+ - `query::where_(nodes, pointer: &str, equals: Option, page: Option)` — full scan of `nodes.iter()`; parse each `raw`, `raw.pointer(pointer)`; match = pointer resolves AND (equals absent OR JSON-equal); rows `[{id, name, type, page_id, value}]` sorted by id. Pointer must start with `/` → else Err.
+ - `query::at(nodes, x: f64, y: f64)` — scan nodes with `abs_bounds = Some([bx, by, w, h])` where `bx <= x < bx+w && by <= y < by+h`; sort by area (w*h) ascending then id; `[{id, name, type, page_id, area}]`.
+- CLI: `figmog stats`, `figmog path `, `figmog text [--page ]`, `figmog where --pointer
[--equals ] [--page ]` (`--equals` parsed with `serde_json::from_str`, falling back to treating the bare word as a JSON string so `--equals VERTICAL` works), `figmog at --x N --y N`. All support `--json`; human output follows the existing row-printing conventions; node ids normalized where they're inputs (`path`).
+
+- [ ] **Step 1 (TDD):** extend `tests/cli.rs` with a test asserting against fixture_v1 facts: `stats` — `by_type.TEXT == 1`, `by_page["0:1"] == 4` (1:1, 1:2, 1:3, 1:9), totals `{components: 3, component_sets: 1, styles: 2}`, `max_depth == 3` (document→canvas→frame→text); `path 1-2` → ids `["0:0","0:1","1:1","1:2"]`; `text` → one row, characters "Welcome to the garden"; `where --pointer /layoutMode --equals VERTICAL` → `["1:1"]`; `where --pointer /style/fontSize --equals 32.0` → `["1:2"]`; `at --x 10 --y 10` → includes `1:1` (bounds 0,0,800×400) and excludes nodes without bounds. Run to verify failure.
+- [ ] **Step 2:** implement `query::*` + CLI wiring; iterate to green; full gates.
+- [ ] **Step 3:** Commit: `feat(figmog): whole-file structural queries (stats/path/text/where/at)`
+
+---
+
+### Task 4: `serve.rs` — the serve loop + CLI wiring
+
+**Files:**
+- Create: `examples/figmog/src/serve.rs`
+- Modify: `examples/figmog/src/lib.rs` (add `pub mod serve;`), `examples/figmog/src/cli.rs` (add `Serve` variant + dispatch)
+
+**Interfaces:**
+- Produces: `pub fn run_serve(db: &crate::cli::Db, file: Option, interval: u64, no_watch: bool) -> Result<(), String>` (make `Db` and the small helpers it needs `pub(crate)`; adjust visibility minimally). CLI: `figmog serve [file] [--interval N (default 10)] [--no-watch]`.
+- Loop design (spec §11): spawn a thread reading `stdin` lines into an `mpsc::Sender`; main loop owns the store (opened via `open_store!` at this concrete site) and a `Watcher` seeded from the stored watermark; `recv_timeout(until_next_tick)` — on message: `mcp::handle_message` → write response + `\n` to stdout, flush; on timeout (and `!no_watch`): tick → on `Changed` run the pull sequence inline (fetch via `UreqApi`, `flatten_file`, `collect_sweepable` in `rtx`, `store::sync`), honoring the existing `pull_failure_wait` backoff discipline and watcher-reset-on-failure rule; on `Wait{after}` extend the next deadline. Startup: if the store has no meta row and `no_watch` is false, do an initial pull before serving. eprintln! one startup line (name, file key, watch on/off).
+- Tool registry: the **17 `figmog_*` tools** from spec §11's two tables (12 core + 5 structural), descriptions stating "reads the local mirror (no Figma API cost)" vs `figmog_sync`'s "fetches from Figma (spends Tier-1 rate budget)". The `initialize` response carries the spec's steering `instructions` text (Task 2 contract). Handler: match tool name → normalize ids (`normalize_node_id` where the arg is a node id) → `st.rtx(|readers| query::*(…))` → the returned Value. `figmog_sync` → the inline pull sequence → churn JSON. Unknown args types → Err(msg).
+- [ ] **Step 1:** Implement `serve.rs` + wire the CLI variant. Keep every closure at the concrete `open_store!` site (the pipeline type is unnameable — same pattern as `dispatch`).
+- [ ] **Step 2:** `cargo test -p figmog` (all green — no new tests yet), clippy, fmt. Manual smoke: `printf '…initialize…\n…tools/list…\n' | cargo run -p figmog -- serve --no-watch --db ` shows two frames on stdout.
+- [ ] **Step 3:** Commit: `feat(figmog): figmog serve — MCP stdio server with integrated sync`
+
+---
+
+### Task 5: end-to-end serve test + docs
+
+**Files:**
+- Create: `examples/figmog/tests/serve.rs`
+- Modify: `examples/figmog/README.md`, workspace `README.md` (figmog bullet mentions MCP)
+
+**Interfaces:** none new.
+
+- [ ] **Step 1:** Write `tests/serve.rs`: build a fixture DB (reuse the `pull --from-file` pattern from `tests/cli.rs` — copy the `fixture_db()` helper or share via `tests/common`), then `std::process::Command` the compiled binary (`assert_cmd::cargo::cargo_bin("figmog")` gives the path) with `serve --no-watch --db `, piped stdio. Write frames, read responses line-by-line with a read timeout guard (wrap reader thread + channel, or set a generous `wait_with_output` after closing stdin — closing stdin must terminate the loop: reader thread sees EOF, sender drops, `recv_timeout` returns Disconnected → clean exit; implement that exit path in Task 3 if missing). Assertions:
+ - initialize response echoes id 1, `serverInfo.name == "figmog"`, and a non-empty `instructions` string mentioning "official Figma MCP"
+ - `tools/list` returns exactly 17 tools, all named `figmog_*`, incl. `figmog_search`, `figmog_where`, `figmog_sync`
+ - `tools/call figmog_search {query:"garden"}` → `isError:false`, text parses to JSON whose first hit id is `1:2`
+ - `tools/call figmog_node {id:"1-2"}` → normalized, `name == "Title"`
+ - `tools/call figmog_where {pointer:"/layoutMode", equals:"VERTICAL"}` → one row, id `1:1`
+ - `tools/call figmog_node {id:"99:99"}` → `isError:true`
+ - unknown method → error `-32601`; unknown tool → `isError:true`
+- [ ] **Step 2:** README: new "Use from agents (MCP)" section — what `serve` is (server + built-in sync, one process), the `claude mcp add figmog -- /target/debug/figmog serve ` snippet (note: build first with `cargo build -p figmog`; or `--db`/`--no-watch` for offline), both tool tables from spec §11 (17 tools), the "radically different from Figma's official MCP" positioning paragraph (namespace, steering instructions, zero capability overlap; only `figmog_sync` spends rate budget), and the five new structural CLI commands added to the command reference table. Workspace README bullet gains "and an MCP server (`figmog serve`)".
+- [ ] **Step 3:** Full gates: `cargo test -p figmog` (now incl. serve e2e), clippy, fmt, `cargo test -p fold`, `cargo doc -p figmog --no-deps`.
+- [ ] **Step 4:** Commit: `feat(figmog): serve e2e tests and MCP docs`
+
+### Task 6: `upstream.rs` — native-server MCP client
+
+**Files:**
+- Create: `examples/figmog/src/upstream.rs`; Modify: `src/lib.rs`
+- Test: unit tests in-file + one in-process HTTP fake test
+
+**Interfaces:** per spec §12 "Upstream client": `UpstreamError` (thiserror: Unreachable(String), Protocol(String)), `trait UpstreamMcp { initialize, tools, call }`, `HttpUpstream::new(url: String)`, and `pub struct FakeUpstream` (test-support, `#[cfg(test)]`-adjacent: put it behind `pub` in the module so tests/serve.rs can script it — a Vec of (name, schema) + a closure or queued results for `call`).
+
+- [ ] **Step 1 (TDD):** unit tests: handshake sequence frames (initialize → notifications/initialized → tools/list) built correctly; `call` builds a valid tools/call frame; `application/json` and single-event `text/event-stream` (`data: {...}\n\n`) bodies both parse; `Mcp-Session-Id` response header echoed on subsequent requests; error mapping. In-process HTTP fake: bind `TcpListener` on port 0, spawn a thread answering scripted HTTP responses, point `HttpUpstream` at it, drive initialize+tools/list+call.
+- [ ] **Step 2:** implement; gates; commit `feat(figmog): upstream MCP client for the Figma desktop server`.
+
+---
+
+### Task 7: proxy cache records + eviction
+
+**Files:**
+- Modify: `examples/figmog/src/model.rs` (Id::ProxyCache, Rec::ProxyCache per spec §12), `src/store.rs` (proxy_cache Table branch in `figmog_pipeline!`; `cache_branch` fn; extend `sync` signature with `evict_cache_before_version: Option<&str>` OR a separate small `evict_stale_cache` helper — pick the design that keeps `sync`'s churn accounting untouched and document it), `src/query.rs` or new `src/cache.rs` (key hashing = stable hash of tool+canonical args — use a simple FNV/deterministic hex of bytes, no new deps; lookup/store helpers taking readers/tx)
+- Test: extend `tests/sync.rs` (cache rows survive no-change pulls, evicted on version change; existing churn numbers UNAFFECTED — cache rows must not perturb the probe counts of existing tests, which don't create cache rows)
+
+- [ ] **Step 1 (TDD):** tests: store a cache row via a wtx helper; identical re-pull keeps it (version unchanged); v1→v2 pull evicts it; imported variables still survive both.
+- [ ] **Step 2:** implement (model variants, pipeline sink `proxy_cache`, helpers); ALL existing tests must stay green with unchanged assertions; gates; commit `feat(figmog): version-keyed proxy response cache`.
+
+---
+
+### Task 8: serve proxy integration + CLI `tools`/`call` + steering v3
+
+**Files:**
+- Modify: `src/serve.rs` (startup probe via `HttpUpstream` unless `--no-upstream`; registry merge per spec §12; route tools/call local-first-else-upstream; cacheable rule (get_*/list_* + explicit node id in args — detect via any arg key in {"nodeId","node_id","id"} with a string value); cache lookup before forward, store after; non-get/list success → immediate meta poll; `figmog_status` gains `upstream` field), `src/mcp.rs` (ONLY the steering-text constant → spec §11 point 3's new verbatim text; update its unit test accordingly — this supersedes the v2 text Task 2 shipped), `src/cli.rs` (`figmog tools`, `figmog call [--args json]`, `--upstream `, `--no-upstream` on serve)
+- Test: unit tests for merge/routing/cacheable-rule with `FakeUpstream`; extend `tests/serve.rs`: one e2e with the in-process HTTP fake upstream (proxied tool listed + round-trips + second identical call served from cache without hitting the fake — assert via the fake's call counter exposed through a side-channel file or header count endpoint)
+
+- [ ] **Step 1 (TDD):** write the failing tests; **Step 2:** implement; gates; commit `feat(figmog): cached proxy — figmog as the single Figma MCP`.
+
+---
+
+### Task 9: Enterprise variables in pull (opportunistic)
+
+**Files:**
+- Modify: `src/api.rs` (`fn variables_local(&self, key) -> Result, ApiError>` on the trait — `Ok(None)` on 403/404), `src/cli.rs` + `src/serve.rs` pull paths (on Some: `parse_variables_export` → extend `flattened.recs` AND the sweepable set with variable/collection ids for THIS pull, per spec §12), README (variables section: Enterprise auto-sync first, plugin export as fallback)
+- Test: sync-level test with a fake api returning the fixture export: pull twice → zero churn on variables; remove one variable from the fake's second response → it is swept; 403 fake → import-variables records still survive pulls (v1 behavior intact)
+
+- [ ] **Step 1 (TDD):** failing tests; **Step 2:** implement; gates; commit `feat(figmog): opportunistic Enterprise variables sync in pull`.
+
+---
+
+## Self-review checklist
+
+- Spec §11 coverage: architecture → T4; query refactor → T1; protocol behaviors incl. `instructions` steering → T2 (unit-tested); structural query pack → T3; 17-tool registry → T4 + T5 README; distinct-namespace/steering rule → T2 (initialize) + T4 (names) + T5 (README positioning); testing section → T2 unit / T1 equivalence / T3 cli / T5 e2e. Non-goals respected (no resources/prompts, stdio only; cached-proxy documented as v3, not built).
+- The T1 refactor is the risk center: its acceptance gate ("existing tests pass unmodified") is what keeps v1 behavior frozen.
+- T4's EOF-exit contract is stated in T5 Step 1 because the test depends on it; implementer of T4 must read T5's step (noted in dispatch).
+- Execution order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 (T3 before T4 so the registry binds all 17 local tools; T6+T7 before T8; T5's e2e baseline exists before T8 extends it).
+- v3 (spec §12) coverage: upstream client → T6; cache records/eviction → T7; registry merge, routing, cacheable rule, steering v3 text (supersedes the v2 text T2 shipped — T8 updates the constant and its unit test), CLI tools/call → T8; Enterprise variables → T9. v3 non-goals respected (no remote-server OAuth proxying, no mid-session re-attach, no selection-call caching).
+- T2 shipped the v2 steering text against the then-current spec; the v3 text lands in T8 by design — reviewers should not flag the interim drift (ledgered).
diff --git a/docs/superpowers/plans/2026-08-15-figmog.md b/docs/superpowers/plans/2026-08-15-figmog.md
new file mode 100644
index 0000000..32ac0e4
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-figmog.md
@@ -0,0 +1,2687 @@
+# figmog Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A fold-backed local mirror of one Figma file: a sync engine that spends Figma rate-limit budget only on real changes, and a CLI whose reads are all local and instant.
+
+**Architecture:** One `KeyedStream` over fjall; a pure `flatten` turns the file-JSON into deterministic records; `KeyedStream::upsert` byte-equality synthesizes the delta API Figma lacks; thirteen CLI subcommands read materialized indexes (tables, multimap, BM25, inverted indexes) from one snapshot.
+
+**Tech Stack:** Rust (edition 2024), fold (workspace path dep), serde/serde_json, ureq 2 (blocking HTTP), clap 4 (derive), thiserror 2. Dev: tempfile, assert_cmd, postcard.
+
+**Spec:** `docs/superpowers/specs/2026-08-15-figmog-build-design.md` — read it first; every design rationale lives there.
+
+## Global Constraints
+
+- Crate is `examples/figmog`, a **lib + thin bin** (`src/lib.rs` + `src/main.rs`) so integration tests can link the modules.
+- **No `ese`, no `anny`, no tokio.** Deps exactly: `fold`, `serde` (derive), `serde_json`, `ureq = "2"`, `clap = "4"` (derive), `thiserror = "2"`. Dev-deps: `tempfile = "3"`, `assert_cmd = "2"`, `postcard = "1"` (use-std).
+- **Determinism:** map-shaped record fields are sorted `Vec`s of pairs, never `HashMap`; `serde_json` must NOT enable `preserve_order`; no wall-clock/randomness inside `flatten`; all CLI output sorted.
+- **Sink names frozen** (on-disk schema): `nodes`, `children`, `text`, `instances_of`, `styled_by`, `bound_to`, `by_type`, `components`, `component_sets`, `styles`, `variables`, `variable_collections`, `meta`.
+- Fixtures are **synthetic only** — nothing derived from the g3d file may land in git.
+- Every task ends green: `cargo test -p figmog` passes, `cargo clippy -p figmog -- -D warnings` clean.
+- If `cargo` is not on PATH in your shell, prefix commands with `export PATH="$HOME/.cargo/bin:$PATH" && `.
+- Commit messages: `feat(figmog): …` / `test(figmog): …` / `docs(figmog): …`, each ending with the `Co-Authored-By: Claude Fable 5 ` trailer.
+
+---
+
+### Task 1: Scaffold the crate (lib + bin)
+
+**Files:**
+- Create: `examples/figmog/Cargo.toml`, `examples/figmog/src/main.rs`, `examples/figmog/src/lib.rs`, empty module files `examples/figmog/src/{model.rs,ident.rs,flatten.rs,store.rs,api.rs,watch.rs,vars.rs,cli.rs}`
+
+**Interfaces:**
+- Produces: crate `figmog` importable from integration tests; `figmog::…` module paths used by every later task.
+
+- [ ] **Step 1: Run the scaffold script from the worktree root**
+
+```bash
+./scripts/new-project.sh figmog
+```
+
+- [ ] **Step 2: Replace the generated Cargo.toml**
+
+```toml
+[package]
+name = "figmog"
+version = "0.0.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+fold = { path = "../../fold" }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+ureq = { version = "2", features = ["json"] }
+clap = { version = "4", features = ["derive"] }
+thiserror = "2"
+
+[dev-dependencies]
+tempfile = "3"
+assert_cmd = "2"
+postcard = { version = "1", features = ["use-std"] }
+```
+
+- [ ] **Step 3: Create `src/lib.rs`**
+
+```rust
+//! figmog — a fold-backed local mirror of one Figma file.
+//!
+//! A sync engine pulls the file when it changes (change detection on the
+//! cheap Tier-3 metadata endpoint, one Tier-1 fetch per real change) and a
+//! `KeyedStream` upsert-diffs every node into materialized indexes. The CLI
+//! reads those indexes locally: zero Figma calls, zero rate limits.
+//!
+//! See `docs/superpowers/specs/2026-08-15-figmog-build-design.md`.
+
+pub mod api;
+pub mod cli;
+pub mod flatten;
+pub mod ident;
+pub mod model;
+pub mod store;
+pub mod vars;
+pub mod watch;
+```
+
+- [ ] **Step 4: Point `src/main.rs` at the lib**
+
+```rust
+fn main() {
+ std::process::exit(figmog::cli::run());
+}
+```
+
+and add a temporary stub so it compiles (replaced in Task 12) — in `src/cli.rs`:
+
+```rust
+//! Command-line surface. Real implementation lands with the CLI tasks.
+pub fn run() -> i32 {
+ eprintln!("figmog: not yet implemented");
+ 2
+}
+```
+
+Leave the other module files as empty files (a lone `//! …` doc line each is fine).
+
+- [ ] **Step 5: Verify it builds and commit**
+
+```bash
+cargo check -p figmog && cargo clippy -p figmog -- -D warnings
+git add examples/figmog Cargo.toml Cargo.lock
+git commit -m "feat(figmog): scaffold lib+bin crate"
+```
+
+(`new-project.sh` relies on the workspace `members = ["examples/*"]` glob, so the root `Cargo.toml` may be untouched — commit whatever changed.)
+
+---
+
+### Task 2: `model.rs` — record types
+
+**Files:**
+- Modify: `examples/figmog/src/model.rs`
+- Test: unit tests in the same file (`#[cfg(test)]`)
+
+**Interfaces:**
+- Produces (used by every later task):
+ - `enum Id { Node(String), Component(String), ComponentSet(String), Style(String), Variable(String), VariableCollection(String), Meta }`
+ - `enum Rec { Node(NodeRec), Component(ComponentRec), ComponentSet(ComponentSetRec), Style(StyleRec), Variable(VariableRec), VariableCollection(VariableCollectionRec), Meta(FileMeta) }`
+ - structs exactly as below; all derive `Debug, Clone, PartialEq, Serialize, Deserialize`; `Id` additionally `Eq, PartialOrd, Ord, Hash`.
+
+- [ ] **Step 1: Write the failing tests** (bottom of `model.rs`)
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_node() -> NodeRec {
+ NodeRec {
+ id: "1:2".into(),
+ parent_id: Some("0:1".into()),
+ child_index: 0,
+ page_id: "0:1".into(),
+ node_type: "TEXT".into(),
+ name: "Title".into(),
+ visible: true,
+ text: Some("hello".into()),
+ component_id: None,
+ component_properties: vec![("Size".into(), "\"Large\"".into())],
+ property_definitions: None,
+ style_refs: vec![("text".into(), "S:2".into())],
+ bound_variables: vec![("/style/fontSize".into(), "VariableID:9".into())],
+ abs_bounds: Some([0.0, 0.0, 100.0, 20.0]),
+ raw: "{}".into(),
+ }
+ }
+
+ #[test]
+ fn rec_postcard_roundtrip() {
+ let rec = Rec::Node(sample_node());
+ let bytes = postcard::to_allocvec(&rec).unwrap();
+ let back: Rec = postcard::from_bytes(&bytes).unwrap();
+ assert_eq!(rec, back);
+ }
+
+ #[test]
+ fn identical_records_encode_identically() {
+ let a = postcard::to_allocvec(&Rec::Node(sample_node())).unwrap();
+ let b = postcard::to_allocvec(&Rec::Node(sample_node())).unwrap();
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn ids_order_and_roundtrip() {
+ let ids = vec![Id::Meta, Id::Node("1:1".into()), Id::Style("S:1".into())];
+ let set: std::collections::BTreeSet = ids.iter().cloned().collect();
+ assert_eq!(set.len(), 3);
+ let bytes = postcard::to_allocvec(&ids).unwrap();
+ let back: Vec = postcard::from_bytes(&bytes).unwrap();
+ assert_eq!(ids, back);
+ }
+}
+```
+
+Note: `postcard` is a dev-dependency, so these unit tests can use it directly.
+
+- [ ] **Step 2: Run to verify failure**
+
+```bash
+cargo test -p figmog model
+```
+Expected: compile FAIL (types not defined).
+
+- [ ] **Step 3: Implement the types**
+
+```rust
+//! Record vocabulary shared by flatten, store, and the CLI.
+//!
+//! Determinism contract: every map-shaped field is a **sorted** `Vec` of
+//! pairs, and canonical-JSON strings come from `serde_json` without
+//! `preserve_order`. `KeyedStream` diffs records by postcard bytes, so two
+//! flattens of the same file JSON must be byte-identical.
+
+use serde::{Deserialize, Serialize};
+
+/// Primary key of every mirrored record.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub enum Id {
+ Node(String),
+ Component(String),
+ ComponentSet(String),
+ Style(String),
+ Variable(String),
+ VariableCollection(String),
+ Meta,
+}
+
+/// One mirrored record; variant always matches its [`Id`] variant.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum Rec {
+ Node(NodeRec),
+ Component(ComponentRec),
+ ComponentSet(ComponentSetRec),
+ Style(StyleRec),
+ Variable(VariableRec),
+ VariableCollection(VariableCollectionRec),
+ Meta(FileMeta),
+}
+
+/// One node of the document tree (children stripped from `raw`).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct NodeRec {
+ pub id: String,
+ pub parent_id: Option,
+ pub child_index: u32,
+ /// Enclosing CANVAS id; the document root and CANVAS nodes carry their own id.
+ pub page_id: String,
+ pub node_type: String,
+ pub name: String,
+ pub visible: bool,
+ /// `characters` for TEXT nodes.
+ pub text: Option,
+ /// INSTANCE → the component's node id.
+ pub component_id: Option,
+ /// INSTANCE `componentProperties` as (name, canonical-JSON value), sorted.
+ pub component_properties: Vec<(String, String)>,
+ /// `componentPropertyDefinitions` (COMPONENT / COMPONENT_SET) as canonical JSON.
+ pub property_definitions: Option,
+ /// Node `styles` map as (style_type, style_id), sorted.
+ pub style_refs: Vec<(String, String)>,
+ /// Variable bindings as (json-pointer to the bound property, variable id), sorted.
+ /// The pointer addresses the *resolved value* location, e.g. `/fills/0/color`.
+ pub bound_variables: Vec<(String, String)>,
+ /// absoluteBoundingBox x, y, w, h.
+ pub abs_bounds: Option<[f64; 4]>,
+ /// Canonical JSON of the node with `children` removed.
+ pub raw: String,
+}
+
+/// Entry of the file response's `components` map.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ComponentRec {
+ pub node_id: String,
+ pub key: String,
+ pub name: String,
+ pub description: String,
+ pub component_set_id: Option,
+ pub remote: bool,
+}
+
+/// Entry of the file response's `componentSets` map.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ComponentSetRec {
+ pub node_id: String,
+ pub key: String,
+ pub name: String,
+ pub description: String,
+ pub remote: bool,
+}
+
+/// Entry of the file response's `styles` map.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct StyleRec {
+ pub style_id: String,
+ pub key: String,
+ pub name: String,
+ pub style_type: String,
+ pub description: String,
+ pub remote: bool,
+}
+
+/// Authoritative variable definition (from `import-variables` only).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct VariableRec {
+ pub id: String,
+ pub name: String,
+ pub resolved_type: String,
+ pub collection_id: String,
+ /// mode id -> value-or-alias, as sorted (mode_id, canonical JSON) pairs.
+ pub values_by_mode: Vec<(String, String)>,
+ pub description: String,
+ pub scopes: Vec,
+}
+
+/// Authoritative variable collection (from `import-variables` only).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct VariableCollectionRec {
+ pub id: String,
+ pub name: String,
+ /// (mode_id, mode_name), sorted by mode_id.
+ pub modes: Vec<(String, String)>,
+ pub default_mode_id: String,
+}
+
+/// The single file-level row (key [`Id::Meta`]).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct FileMeta {
+ pub name: String,
+ pub version: String,
+ pub last_modified: String,
+ pub synced_at_unix_ms: u64,
+}
+```
+
+- [ ] **Step 4: Run tests + clippy**
+
+```bash
+cargo test -p figmog model && cargo clippy -p figmog -- -D warnings
+```
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/model.rs
+git commit -m "feat(figmog): record model (Id/Rec + node and design-system records)"
+```
+
+---
+
+### Task 3: `ident.rs` — file/node identity parsing
+
+**Files:**
+- Modify: `examples/figmog/src/ident.rs`
+- Test: unit tests in the same file
+
+**Interfaces:**
+- Produces:
+ - `pub fn parse_file_ref(input: &str) -> Option` — bare key or figma URL → file key
+ - `pub fn normalize_node_id(input: &str) -> String` — `12-34` → `12:34`; already-canonical and instance-path ids (`I12:34;56:78`) pass through
+
+- [ ] **Step 1: Write the failing tests**
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accepts_bare_key() {
+ assert_eq!(parse_file_ref("flAtUnMfzvA5daBSTFQK35").as_deref(), Some("flAtUnMfzvA5daBSTFQK35"));
+ }
+
+ #[test]
+ fn parses_design_and_file_urls() {
+ for url in [
+ "https://www.figma.com/design/flAtUnMfzvA5daBSTFQK35/g3d-Index-Web-Handoff?node-id=0-1&t=x-1",
+ "https://www.figma.com/file/flAtUnMfzvA5daBSTFQK35/whatever",
+ "figma.com/design/flAtUnMfzvA5daBSTFQK35",
+ ] {
+ assert_eq!(parse_file_ref(url).as_deref(), Some("flAtUnMfzvA5daBSTFQK35"), "{url}");
+ }
+ }
+
+ #[test]
+ fn rejects_garbage() {
+ assert_eq!(parse_file_ref("https://example.com/nope"), None);
+ assert_eq!(parse_file_ref("not a key!"), None);
+ assert_eq!(parse_file_ref(""), None);
+ }
+
+ #[test]
+ fn normalizes_node_ids() {
+ assert_eq!(normalize_node_id("0-1"), "0:1");
+ assert_eq!(normalize_node_id("12-345"), "12:345");
+ assert_eq!(normalize_node_id("12:345"), "12:345");
+ // instance sub-node paths pass through untouched
+ assert_eq!(normalize_node_id("I206:7;104:22"), "I206:7;104:22");
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog ident` → compile FAIL.
+
+- [ ] **Step 3: Implement**
+
+```rust
+//! Parsing of user-supplied file references and node ids.
+
+/// Extract a file key from a bare key or a figma.com URL
+/// (`figma.com/design//…`, `figma.com/file//…`).
+pub fn parse_file_ref(input: &str) -> Option {
+ let is_key = |s: &str| {
+ s.len() >= 10 && s.chars().all(|c| c.is_ascii_alphanumeric())
+ };
+ if is_key(input) {
+ return Some(input.to_string());
+ }
+ let rest = input
+ .split_once("figma.com/")
+ .map(|(_, r)| r)?;
+ let mut parts = rest.split('/');
+ match parts.next()? {
+ "design" | "file" | "board" => {}
+ _ => return None,
+ }
+ let key = parts.next()?;
+ is_key(key).then(|| key.to_string())
+}
+
+/// Canonicalize a node id: URLs write `12:34` as `12-34`. Ids that are not
+/// exactly `-` (already-canonical ids, instance paths like
+/// `I206:7;104:22`) pass through unchanged.
+pub fn normalize_node_id(input: &str) -> String {
+ if let Some((a, b)) = input.split_once('-') {
+ let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
+ if digits(a) && digits(b) {
+ return format!("{a}:{b}");
+ }
+ }
+ input.to_string()
+}
+```
+
+- [ ] **Step 4: Run tests + clippy** — `cargo test -p figmog ident && cargo clippy -p figmog -- -D warnings` → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/ident.rs
+git commit -m "feat(figmog): file/node identity parsing"
+```
+
+---
+
+### Task 4: fixture builder + `flatten` core (tree walk)
+
+**Files:**
+- Modify: `examples/figmog/src/flatten.rs`
+- Create: `examples/figmog/tests/common/mod.rs` (fixture builders)
+- Create: `examples/figmog/tests/flatten.rs`
+
+**Interfaces:**
+- Consumes: `figmog::model::*`.
+- Produces:
+ - `pub struct Flattened { pub recs: Vec<(Id, Rec)>, pub file: FileInfo }`
+ - `pub struct FileInfo { pub name: String, pub version: String, pub last_modified: String }`
+ - `pub fn flatten_file(resp: &serde_json::Value) -> Result`
+ - `#[derive(Debug, thiserror::Error)] pub enum FlattenError { #[error("missing field: {0}")] Missing(&'static str) }`
+ - test helper `common::fixture_v1() -> serde_json::Value` (and, in Task 7, `common::fixture_v2()`).
+- Design-system fields (`component_id`, `component_properties`, `property_definitions`, `style_refs`, `bound_variables`) and the components/styles maps land in **Task 5**; this task leaves them empty (`vec![]` / `None`) and only walks the tree.
+
+- [ ] **Step 1: Write the fixture builder** — `tests/common/mod.rs`. This is the single synthetic file used by all tests; content matters, copy it exactly:
+
+```rust
+//! Synthetic Figma file fixtures. Deliberately NOT derived from any real
+//! file. Shape mirrors GET /v1/files/:key responses.
+
+use serde_json::{Value, json};
+
+/// 12 nodes over 3 pages: a hero frame with a text, a variant'd button
+/// instance, an invisible node, a component set (2 variants), a standalone
+/// component, and an empty page. Fill/text styles + variable bindings.
+pub fn fixture_v1() -> Value {
+ json!({
+ "name": "Fixture",
+ "version": "100",
+ "lastModified": "2026-08-01T00:00:00Z",
+ "document": {
+ "id": "0:0", "name": "Document", "type": "DOCUMENT",
+ "children": [
+ { "id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [
+ { "id": "1:1", "name": "Hero", "type": "FRAME",
+ "absoluteBoundingBox": {"x": 0.0, "y": 0.0, "width": 800.0, "height": 400.0},
+ "layoutMode": "VERTICAL", "paddingLeft": 16.0,
+ "boundVariables": { "paddingLeft": {"type": "VARIABLE_ALIAS", "id": "VariableID:200"} },
+ "fills": [ { "type": "SOLID",
+ "color": {"r": 0.06, "g": 0.13, "b": 0.2, "a": 1.0},
+ "boundVariables": { "color": {"type": "VARIABLE_ALIAS", "id": "VariableID:100"} } } ],
+ "styles": { "fill": "S:1" },
+ "children": [
+ { "id": "1:2", "name": "Title", "type": "TEXT",
+ "characters": "Welcome to the garden",
+ "style": {"fontFamily": "Basis", "fontSize": 32.0, "fontWeight": 500},
+ "styles": { "text": "S:2" },
+ "children": [] },
+ { "id": "1:3", "name": "Button", "type": "INSTANCE",
+ "componentId": "2:2",
+ "componentProperties": {
+ "Size": {"value": "Large", "type": "VARIANT"},
+ "State": {"value": "Default", "type": "VARIANT"},
+ "Label": {"value": "Go", "type": "TEXT"},
+ "HasIcon": {"value": false, "type": "BOOLEAN"},
+ "Icon": {"value": "3:1", "type": "INSTANCE_SWAP"}
+ },
+ "children": [] }
+ ] },
+ { "id": "1:9", "name": "Old badge", "type": "RECTANGLE",
+ "visible": false, "children": [] }
+ ] },
+ { "id": "0:2", "name": "Components", "type": "CANVAS", "children": [
+ { "id": "2:1", "name": "Button", "type": "COMPONENT_SET",
+ "componentPropertyDefinitions": {
+ "Size": {"type": "VARIANT", "defaultValue": "Large", "variantOptions": ["Large", "Small"]},
+ "State": {"type": "VARIANT", "defaultValue": "Default", "variantOptions": ["Default", "Hover"]},
+ "Label": {"type": "TEXT", "defaultValue": "Go"},
+ "HasIcon": {"type": "BOOLEAN", "defaultValue": false},
+ "Icon": {"type": "INSTANCE_SWAP", "defaultValue": "3:1"}
+ },
+ "children": [
+ { "id": "2:2", "name": "Size=Large, State=Default", "type": "COMPONENT", "children": [] },
+ { "id": "2:3", "name": "Size=Small, State=Hover", "type": "COMPONENT", "children": [] }
+ ] },
+ { "id": "3:1", "name": "IconStar", "type": "COMPONENT", "children": [] }
+ ] },
+ { "id": "0:3", "name": "Empty", "type": "CANVAS", "children": [] }
+ ]
+ },
+ "components": {
+ "2:2": {"key": "key22", "name": "Size=Large, State=Default", "description": "", "componentSetId": "2:1", "remote": false},
+ "2:3": {"key": "key23", "name": "Size=Small, State=Hover", "description": "", "componentSetId": "2:1", "remote": false},
+ "3:1": {"key": "key31", "name": "IconStar", "description": "a star", "remote": false}
+ },
+ "componentSets": {
+ "2:1": {"key": "keyset21", "name": "Button", "description": "the button", "remote": false}
+ },
+ "styles": {
+ "S:1": {"key": "sk1", "name": "Brand/Primary", "styleType": "FILL", "description": "", "remote": false},
+ "S:2": {"key": "sk2", "name": "Heading/H1", "styleType": "TEXT", "description": "", "remote": false}
+ }
+ })
+}
+```
+
+- [ ] **Step 2: Write the failing tests** — `tests/flatten.rs`:
+
+```rust
+mod common;
+
+use figmog::flatten::flatten_file;
+use figmog::model::{Id, Rec};
+
+fn node(recs: &[(Id, Rec)], id: &str) -> figmog::model::NodeRec {
+ recs.iter()
+ .find_map(|(k, r)| match (k, r) {
+ (Id::Node(n), Rec::Node(rec)) if n == id => Some(rec.clone()),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("node {id} not flattened"))
+}
+
+#[test]
+fn walks_the_whole_tree() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let node_ids: Vec<&str> = out
+ .recs
+ .iter()
+ .filter_map(|(k, _)| match k { Id::Node(n) => Some(n.as_str()), _ => None })
+ .collect();
+ assert_eq!(
+ node_ids,
+ ["0:0", "0:1", "1:1", "1:2", "1:3", "1:9", "0:2", "2:1", "2:2", "2:3", "3:1", "0:3"],
+ "depth-first order, all 12 nodes"
+ );
+ assert_eq!(out.file.name, "Fixture");
+ assert_eq!(out.file.version, "100");
+ assert_eq!(out.file.last_modified, "2026-08-01T00:00:00Z");
+}
+
+#[test]
+fn parent_index_page_attribution() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let title = node(&out.recs, "1:2");
+ assert_eq!(title.parent_id.as_deref(), Some("1:1"));
+ assert_eq!(title.child_index, 0);
+ assert_eq!(title.page_id, "0:1");
+ let button = node(&out.recs, "1:3");
+ assert_eq!(button.child_index, 1);
+
+ let root = node(&out.recs, "0:0");
+ assert_eq!(root.parent_id, None);
+ assert_eq!(root.page_id, "0:0");
+ let canvas = node(&out.recs, "0:2");
+ assert_eq!(canvas.page_id, "0:2");
+ let variant = node(&out.recs, "2:2");
+ assert_eq!(variant.page_id, "0:2");
+}
+
+#[test]
+fn basic_fields() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let title = node(&out.recs, "1:2");
+ assert_eq!(title.node_type, "TEXT");
+ assert_eq!(title.name, "Title");
+ assert!(title.visible);
+ assert_eq!(title.text.as_deref(), Some("Welcome to the garden"));
+
+ let hidden = node(&out.recs, "1:9");
+ assert!(!hidden.visible);
+
+ let hero = node(&out.recs, "1:1");
+ assert_eq!(hero.abs_bounds, Some([0.0, 0.0, 800.0, 400.0]));
+}
+
+#[test]
+fn raw_is_canonical_and_childless() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let hero = node(&out.recs, "1:1");
+ let raw: serde_json::Value = serde_json::from_str(&hero.raw).unwrap();
+ assert!(raw.get("children").is_none());
+ assert_eq!(raw["name"], "Hero");
+ // canonical: re-serializing the parsed value reproduces the string
+ assert_eq!(serde_json::to_string(&raw).unwrap(), hero.raw);
+}
+
+#[test]
+fn deterministic_bytes() {
+ let a = flatten_file(&common::fixture_v1()).unwrap();
+ let b = flatten_file(&common::fixture_v1()).unwrap();
+ let enc = |f: &figmog::flatten::Flattened| postcard::to_allocvec(&f.recs).unwrap();
+ assert_eq!(enc(&a), enc(&b));
+}
+
+#[test]
+fn missing_document_errors() {
+ assert!(flatten_file(&serde_json::json!({"name": "x"})).is_err());
+}
+```
+
+- [ ] **Step 3: Run to verify failure** — `cargo test -p figmog --test flatten` → compile FAIL (`flatten_file` undefined).
+
+- [ ] **Step 4: Implement `flatten.rs` (core walk)**
+
+```rust
+//! Pure flattening of a Figma file response into deterministic records.
+//!
+//! No I/O, no clock, no randomness: two calls on equal JSON must produce
+//! byte-identical records (postcard), because `KeyedStream::upsert` uses
+//! byte equality as its change detector.
+
+use serde_json::Value;
+
+use crate::model::{Id, NodeRec, Rec};
+
+/// File-level fields lifted from the response envelope.
+#[derive(Debug, Clone, PartialEq)]
+pub struct FileInfo {
+ pub name: String,
+ pub version: String,
+ pub last_modified: String,
+}
+
+/// Everything `flatten_file` extracts.
+#[derive(Debug)]
+pub struct Flattened {
+ pub recs: Vec<(Id, Rec)>,
+ pub file: FileInfo,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum FlattenError {
+ #[error("missing field: {0}")]
+ Missing(&'static str),
+}
+
+/// Flatten a full `GET /v1/files/:key` response.
+pub fn flatten_file(resp: &Value) -> Result {
+ let file = FileInfo {
+ name: str_field(resp, "name").ok_or(FlattenError::Missing("name"))?,
+ version: str_field(resp, "version").ok_or(FlattenError::Missing("version"))?,
+ last_modified: str_field(resp, "lastModified").ok_or(FlattenError::Missing("lastModified"))?,
+ };
+ let document = resp.get("document").ok_or(FlattenError::Missing("document"))?;
+
+ let mut recs = Vec::new();
+ walk(document, None, 0, None, &mut recs);
+ Ok(Flattened { recs, file })
+}
+
+fn str_field(v: &Value, k: &str) -> Option {
+ v.get(k)?.as_str().map(str::to_string)
+}
+
+/// Depth-first walk. `page_id` is the nearest CANVAS ancestor (None above
+/// pages — the record then carries the node's own id).
+fn walk(
+ node: &Value,
+ parent_id: Option<&str>,
+ child_index: u32,
+ page_id: Option<&str>,
+ out: &mut Vec<(Id, Rec)>,
+) {
+ let Some(id) = node.get("id").and_then(Value::as_str) else {
+ return; // node without id: skip it and its subtree
+ };
+ let node_type = node
+ .get("type")
+ .and_then(Value::as_str)
+ .unwrap_or("UNKNOWN")
+ .to_string();
+ let own_page = matches!(node_type.as_str(), "DOCUMENT" | "CANVAS");
+ let page = if own_page { id } else { page_id.unwrap_or(id) };
+
+ let mut raw = node.clone();
+ if let Some(obj) = raw.as_object_mut() {
+ obj.remove("children");
+ }
+
+ let rec = NodeRec {
+ id: id.to_string(),
+ parent_id: parent_id.map(str::to_string),
+ child_index,
+ page_id: page.to_string(),
+ node_type,
+ name: str_field(node, "name").unwrap_or_default(),
+ visible: node.get("visible").and_then(Value::as_bool).unwrap_or(true),
+ text: str_field(node, "characters"),
+ component_id: None,
+ component_properties: Vec::new(),
+ property_definitions: None,
+ style_refs: Vec::new(),
+ bound_variables: Vec::new(),
+ abs_bounds: node.get("absoluteBoundingBox").and_then(|b| {
+ Some([
+ b.get("x")?.as_f64()?,
+ b.get("y")?.as_f64()?,
+ b.get("width")?.as_f64()?,
+ b.get("height")?.as_f64()?,
+ ])
+ }),
+ raw: serde_json::to_string(&raw).expect("serde_json::Value serializes"),
+ };
+ out.push((Id::Node(id.to_string()), Rec::Node(rec)));
+
+ if let Some(children) = node.get("children").and_then(Value::as_array) {
+ for (i, child) in children.iter().enumerate() {
+ walk(child, Some(id), i as u32, Some(page), out);
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Run tests + clippy**
+
+```bash
+cargo test -p figmog --test flatten && cargo test -p figmog && cargo clippy -p figmog -- -D warnings
+```
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add examples/figmog/src/flatten.rs examples/figmog/tests
+git commit -m "feat(figmog): flatten core tree walk + synthetic fixture"
+```
+
+---
+
+### Task 5: `flatten` design-system fields + envelope maps
+
+**Files:**
+- Modify: `examples/figmog/src/flatten.rs`
+- Modify: `examples/figmog/tests/flatten.rs` (add tests)
+
+**Interfaces:**
+- Consumes/Produces: same `flatten_file`; `NodeRec` design-system fields now populated; `recs` additionally contains `Component`/`ComponentSet`/`Style` records, emitted **after** all nodes, in sorted-key order.
+
+- [ ] **Step 1: Add the failing tests** to `tests/flatten.rs`:
+
+```rust
+use figmog::model::{ComponentRec, StyleRec};
+
+fn component(recs: &[(Id, Rec)], id: &str) -> ComponentRec {
+ recs.iter()
+ .find_map(|(k, r)| match (k, r) {
+ (Id::Component(n), Rec::Component(rec)) if n == id => Some(rec.clone()),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("component {id} not flattened"))
+}
+
+#[test]
+fn instance_component_fields() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let button = node(&out.recs, "1:3");
+ assert_eq!(button.component_id.as_deref(), Some("2:2"));
+ // sorted by property name; values are canonical JSON of the `value` field
+ assert_eq!(
+ button.component_properties,
+ vec![
+ ("HasIcon".to_string(), "false".to_string()),
+ ("Icon".to_string(), "\"3:1\"".to_string()),
+ ("Label".to_string(), "\"Go\"".to_string()),
+ ("Size".to_string(), "\"Large\"".to_string()),
+ ("State".to_string(), "\"Default\"".to_string()),
+ ]
+ );
+}
+
+#[test]
+fn property_definitions_on_set_and_component() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let set = node(&out.recs, "2:1");
+ let defs: serde_json::Value =
+ serde_json::from_str(set.property_definitions.as_deref().unwrap()).unwrap();
+ assert_eq!(defs["Size"]["variantOptions"], serde_json::json!(["Large", "Small"]));
+ // standalone component without the field -> None
+ assert_eq!(node(&out.recs, "3:1").property_definitions, None);
+}
+
+#[test]
+fn style_refs_extracted_sorted() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ assert_eq!(node(&out.recs, "1:1").style_refs, vec![("fill".to_string(), "S:1".to_string())]);
+ assert_eq!(node(&out.recs, "1:2").style_refs, vec![("text".to_string(), "S:2".to_string())]);
+}
+
+#[test]
+fn bound_variable_scan_finds_all_depths() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let hero = node(&out.recs, "1:1");
+ // sorted by pointer; pointer addresses the RESOLVED value location
+ assert_eq!(
+ hero.bound_variables,
+ vec![
+ ("/fills/0/color".to_string(), "VariableID:100".to_string()),
+ ("/paddingLeft".to_string(), "VariableID:200".to_string()),
+ ]
+ );
+}
+
+#[test]
+fn envelope_maps_flattened() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let c = component(&out.recs, "2:2");
+ assert_eq!(c.key, "key22");
+ assert_eq!(c.component_set_id.as_deref(), Some("2:1"));
+ assert!(!c.remote);
+
+ let styles: Vec = out.recs.iter()
+ .filter_map(|(_, r)| match r { Rec::Style(s) => Some(s.clone()), _ => None })
+ .collect();
+ assert_eq!(styles.len(), 2);
+ assert_eq!(styles[0].style_id, "S:1"); // sorted by style id
+ assert_eq!(styles[0].style_type, "FILL");
+
+ let sets = out.recs.iter().filter(|(k, _)| matches!(k, Id::ComponentSet(_))).count();
+ assert_eq!(sets, 1);
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test flatten` → FAILs (fields empty, maps missing).
+
+- [ ] **Step 3: Implement.** In `walk`, replace the four placeholder fields:
+
+```rust
+ component_id: str_field(node, "componentId"),
+ component_properties: sorted_map(node.get("componentProperties"), |v| {
+ v.get("value").map(|val| serde_json::to_string(val).expect("Value serializes"))
+ }),
+ property_definitions: node
+ .get("componentPropertyDefinitions")
+ .map(|v| serde_json::to_string(v).expect("Value serializes")),
+ style_refs: sorted_map(node.get("styles"), |v| v.as_str().map(str::to_string)),
+ bound_variables: scan_bound_variables(&raw),
+```
+
+with the helpers (place after `walk`):
+
+```rust
+/// Turn a JSON object into sorted (key, f(value)) pairs; absent/None entries drop.
+fn sorted_map(obj: Option<&Value>, f: impl Fn(&Value) -> Option) -> Vec<(String, String)> {
+ let mut out: Vec<(String, String)> = obj
+ .and_then(Value::as_object)
+ .map(|m| {
+ m.iter()
+ .filter_map(|(k, v)| Some((k.clone(), f(v)?)))
+ .collect()
+ })
+ .unwrap_or_default();
+ out.sort();
+ out
+}
+
+/// Recursively find every `boundVariables` object and emit
+/// (pointer-to-resolved-value, variable id) pairs. The binding
+/// `…/boundVariables/ = {type: VARIABLE_ALIAS, id}` resolves at the
+/// sibling `…/`, which is where Figma bakes the concrete value.
+fn scan_bound_variables(node_raw: &Value) -> Vec<(String, String)> {
+ let mut out = Vec::new();
+ scan_bv(node_raw, "", &mut out);
+ out.sort();
+ out.dedup();
+ out
+}
+
+fn scan_bv(v: &Value, path: &str, out: &mut Vec<(String, String)>) {
+ match v {
+ Value::Object(map) => {
+ for (k, child) in map {
+ if k == "boundVariables" {
+ collect_aliases(child, path, out);
+ } else {
+ scan_bv(child, &format!("{path}/{k}"), out);
+ }
+ }
+ }
+ Value::Array(items) => {
+ for (i, child) in items.iter().enumerate() {
+ scan_bv(child, &format!("{path}/{i}"), out);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Walk the *inside* of a `boundVariables` object: values are aliases,
+/// arrays of aliases, or nested objects of them.
+fn collect_aliases(v: &Value, prop_path: &str, out: &mut Vec<(String, String)>) {
+ match v {
+ Value::Object(map) => {
+ let alias = map.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS");
+ if alias && let Some(id) = map.get("id").and_then(Value::as_str) {
+ out.push((prop_path.to_string(), id.to_string()));
+ return;
+ }
+ for (k, child) in map {
+ collect_aliases(child, &format!("{prop_path}/{k}"), out);
+ }
+ }
+ Value::Array(items) => {
+ for (i, child) in items.iter().enumerate() {
+ collect_aliases(child, &format!("{prop_path}/{i}"), out);
+ }
+ }
+ _ => {}
+ }
+}
+```
+
+Then, in `flatten_file` after the walk, flatten the envelope maps (BTreeMap iteration = sorted keys = deterministic order):
+
+```rust
+use crate::model::{ComponentRec, ComponentSetRec, StyleRec};
+use std::collections::BTreeMap;
+
+ let obj_map = |key: &str| -> BTreeMap {
+ resp.get(key)
+ .and_then(Value::as_object)
+ .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
+ .unwrap_or_default()
+ };
+ for (node_id, v) in obj_map("components") {
+ recs.push((
+ Id::Component(node_id.clone()),
+ Rec::Component(ComponentRec {
+ node_id,
+ key: str_field(&v, "key").unwrap_or_default(),
+ name: str_field(&v, "name").unwrap_or_default(),
+ description: str_field(&v, "description").unwrap_or_default(),
+ component_set_id: str_field(&v, "componentSetId"),
+ remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false),
+ }),
+ ));
+ }
+ for (node_id, v) in obj_map("componentSets") {
+ recs.push((
+ Id::ComponentSet(node_id.clone()),
+ Rec::ComponentSet(ComponentSetRec {
+ node_id,
+ key: str_field(&v, "key").unwrap_or_default(),
+ name: str_field(&v, "name").unwrap_or_default(),
+ description: str_field(&v, "description").unwrap_or_default(),
+ remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false),
+ }),
+ ));
+ }
+ for (style_id, v) in obj_map("styles") {
+ recs.push((
+ Id::Style(style_id.clone()),
+ Rec::Style(StyleRec {
+ style_id,
+ key: str_field(&v, "key").unwrap_or_default(),
+ name: str_field(&v, "name").unwrap_or_default(),
+ style_type: str_field(&v, "styleType").unwrap_or_default(),
+ description: str_field(&v, "description").unwrap_or_default(),
+ remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false),
+ }),
+ ));
+ }
+```
+
+Note the `scan_bound_variables(&raw)` call receives the children-stripped value, so bindings inside child nodes are attributed to the child (its own walk), never duplicated on the parent.
+
+- [ ] **Step 4: Run all tests + clippy** — `cargo test -p figmog && cargo clippy -p figmog -- -D warnings` → PASS (including the determinism test, which now covers the new fields).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/flatten.rs examples/figmog/tests/flatten.rs
+git commit -m "feat(figmog): flatten design-system fields and envelope maps"
+```
+
+---
+
+### Task 6: `store.rs` — pipeline + sync (populate & no-churn)
+
+**Files:**
+- Modify: `examples/figmog/src/store.rs`
+- Create: `examples/figmog/tests/sync.rs`
+
+**Interfaces:**
+- Consumes: `model::*`, `flatten::FileInfo`.
+- Produces:
+ - branch functions (pub, used by the macro): `node_only`, `child_edge`, `text_doc`, `instance_edge`, `style_edges`, `variable_edges`, `type_edge`, `component_only`, `component_set_only`, `style_only`, `variable_only`, `collection_only`, `meta_only` — signatures in the code below.
+ - `#[macro_export] macro_rules! figmog_pipeline` — expands to the pipeline value; `#[macro_export] macro_rules! open_store` — `open_store!(path)` → `KeyedStream` (unnameable type; bind with `let`).
+ - `pub struct Churn { pub added: usize, pub changed: usize, pub removed: usize, pub unchanged: usize }` (derives `Debug, Default, PartialEq, Serialize`)
+ - `pub fn sync>>(st: &mut KeyedStream, prior_sweepable: &BTreeSet, flattened: &Flattened, synced_at_unix_ms: u64) -> Churn` — one `wtx`: upserts every rec + the meta rec, removes vanished sweepable ids. `Id::Variable`/`Id::VariableCollection`/`Id::Meta` are never swept.
+ - `pub fn collect_sweepable(…)` — see code; gathers prior node/component/set/style ids from readers.
+ - Reader tuple shape (every read site destructures exactly this):
+ `((nodes, children, text, instances_of, styled_by, bound_to, by_type), components, component_sets, styles, variables, variable_collections, meta)`
+ with element types
+ `TableReader<'_, R, String, NodeRec>`, `MultimapReader<'_, R, String, (u32, String)>`, `Bm25Reader<'_, R, String>` (whatever fold names it — check `fold::pipeline::terminal::search` exports at implementation time), `InvertedIndexReader<'_, R, String, String>` ×3, `TableReader` for the rest, `TableReader<'_, R, (), FileMeta>` for meta.
+
+- [ ] **Step 1: Implement `store.rs`** (pipeline shape is fixed by spec §5; write it first — the tests drive `sync` behavior, not the shape):
+
+```rust
+//! Pipeline definition and the sync transaction.
+//!
+//! The pipeline type contains fn items and so can't be written down; the
+//! `figmog_pipeline!` / `open_store!` macros expand it at each use site
+//! (main + tests). Everything else here is ordinary generic functions.
+
+use std::collections::BTreeSet;
+
+use fold::pipeline::{Keyed, Push};
+use fold::stream::KeyedStream;
+use serde::Serialize;
+
+use crate::flatten::Flattened;
+use crate::model::{FileMeta, Id, NodeRec, Rec};
+
+// ---- pipeline branch functions (pure; fold requires determinism) ----
+
+pub fn node_only(d: &Keyed) -> Option> {
+ match &d.val {
+ Rec::Node(n) => Some(Keyed::new(n.id.clone(), n.clone())),
+ _ => None,
+ }
+}
+
+pub fn child_edge(d: &Keyed) -> Option> {
+ let parent = d.val.parent_id.clone()?;
+ Some(Keyed::new(parent, (d.val.child_index, d.val.id.clone())))
+}
+
+pub fn text_doc(d: &Keyed) -> Option> {
+ let mut s = d.val.name.clone();
+ if let Some(t) = &d.val.text {
+ s.push(' ');
+ s.push_str(t);
+ }
+ let s = s.trim().to_string();
+ (!s.is_empty()).then(|| Keyed::new(d.val.id.clone(), s))
+}
+
+pub fn instance_edge(d: &Keyed) -> Option> {
+ d.val
+ .component_id
+ .clone()
+ .map(|c| Keyed::new(d.val.id.clone(), c))
+}
+
+pub fn style_edges(d: &Keyed) -> Vec> {
+ d.val
+ .style_refs
+ .iter()
+ .map(|(_, style_id)| Keyed::new(d.val.id.clone(), style_id.clone()))
+ .collect()
+}
+
+pub fn variable_edges(d: &Keyed) -> Vec> {
+ let mut edges: Vec<_> = d
+ .val
+ .bound_variables
+ .iter()
+ .map(|(_, var_id)| Keyed::new(d.val.id.clone(), var_id.clone()))
+ .collect();
+ edges.dedup_by(|a, b| a.val == b.val); // sorted input: dedup repeated ids
+ edges
+}
+
+pub fn type_edge(d: &Keyed) -> Keyed {
+ Keyed::new(d.val.id.clone(), d.val.node_type.clone())
+}
+
+macro_rules! rec_branch {
+ ($name:ident, $idvar:ident, $recvar:ident, $rec:ty) => {
+ pub fn $name(d: &Keyed) -> Option> {
+ match (&d.key, &d.val) {
+ (Id::$idvar(k), Rec::$recvar(r)) => Some(Keyed::new(k.clone(), r.clone())),
+ _ => None,
+ }
+ }
+ };
+}
+rec_branch!(component_only, Component, Component, crate::model::ComponentRec);
+rec_branch!(component_set_only, ComponentSet, ComponentSet, crate::model::ComponentSetRec);
+rec_branch!(style_only, Style, Style, crate::model::StyleRec);
+rec_branch!(variable_only, Variable, Variable, crate::model::VariableRec);
+rec_branch!(collection_only, VariableCollection, VariableCollection, crate::model::VariableCollectionRec);
+
+pub fn meta_only(d: &Keyed) -> Option> {
+ match &d.val {
+ Rec::Meta(m) => Some(Keyed::new((), m.clone())),
+ _ => None,
+ }
+}
+
+/// The full figmog pipeline. Sink names are frozen on-disk schema.
+#[macro_export]
+macro_rules! figmog_pipeline {
+ () => {{
+ use fold::pipeline::{FilterMap, FlatMap, Map, terminal};
+ (
+ FilterMap::new(
+ $crate::store::node_only,
+ (
+ terminal::Table::new("nodes"),
+ FilterMap::new($crate::store::child_edge, terminal::Multimap::new("children")),
+ FilterMap::new($crate::store::text_doc, terminal::search::Bm25::new("text")),
+ FilterMap::new($crate::store::instance_edge, terminal::InvertedIndex::new("instances_of")),
+ FlatMap::new($crate::store::style_edges, terminal::InvertedIndex::new("styled_by")),
+ FlatMap::new($crate::store::variable_edges, terminal::InvertedIndex::new("bound_to")),
+ Map::new($crate::store::type_edge, terminal::InvertedIndex::new("by_type")),
+ ),
+ ),
+ FilterMap::new($crate::store::component_only, terminal::Table::new("components")),
+ FilterMap::new($crate::store::component_set_only, terminal::Table::new("component_sets")),
+ FilterMap::new($crate::store::style_only, terminal::Table::new("styles")),
+ FilterMap::new($crate::store::variable_only, terminal::Table::new("variables")),
+ FilterMap::new($crate::store::collection_only, terminal::Table::new("variable_collections")),
+ FilterMap::new($crate::store::meta_only, terminal::Table::new("meta")),
+ )
+ }};
+}
+
+/// Open (or create) the figmog store at `$path`.
+#[macro_export]
+macro_rules! open_store {
+ ($path:expr) => {
+ ::fold::stream::KeyedStream::<$crate::model::Id, $crate::model::Rec, _>::new(
+ $path,
+ $crate::figmog_pipeline!(),
+ )
+ };
+}
+
+// ---- sync ----
+
+/// What one sync did, per record.
+#[derive(Debug, Default, PartialEq, Serialize)]
+pub struct Churn {
+ pub added: usize,
+ pub changed: usize,
+ pub removed: usize,
+ pub unchanged: usize,
+}
+
+/// Apply a flattened file in one atomic transaction: upsert every record
+/// and the meta row, then remove previously-stored ids that vanished.
+/// Variables, collections, and the meta row are exempt from the sweep.
+pub fn sync>>(
+ st: &mut KeyedStream,
+ prior_sweepable: &BTreeSet,
+ flattened: &Flattened,
+ synced_at_unix_ms: u64,
+) -> Churn {
+ let meta = FileMeta {
+ name: flattened.file.name.clone(),
+ version: flattened.file.version.clone(),
+ last_modified: flattened.file.last_modified.clone(),
+ synced_at_unix_ms,
+ };
+ let live: BTreeSet<&Id> = flattened.recs.iter().map(|(id, _)| id).collect();
+
+ let mut churn = Churn::default();
+ st.wtx(|tx| {
+ for (id, rec) in &flattened.recs {
+ match tx.upsert(id, rec) {
+ None => churn.added += 1,
+ Some(old) if old == *rec => churn.unchanged += 1,
+ Some(_) => churn.changed += 1,
+ }
+ }
+ tx.upsert(&Id::Meta, &Rec::Meta(meta));
+ for id in prior_sweepable {
+ if !live.contains(id) {
+ debug_assert!(!matches!(
+ id,
+ Id::Variable(_) | Id::VariableCollection(_) | Id::Meta
+ ));
+ if tx.remove(id).is_some() {
+ churn.removed += 1;
+ }
+ }
+ }
+ });
+ churn
+}
+
+/// Gather the sweepable id set from the four table readers. Call inside
+/// `rtx` *before* `sync` (single-writer process: no write races).
+pub fn collect_sweepable(
+ nodes: &fold::pipeline::terminal::TableReader<'_, R, String, NodeRec>,
+ components: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentRec>,
+ component_sets: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentSetRec>,
+ styles: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::StyleRec>,
+) -> BTreeSet {
+ let mut out = BTreeSet::new();
+ out.extend(nodes.iter().map(|(k, _)| Id::Node(k)));
+ out.extend(components.iter().map(|(k, _)| Id::Component(k)));
+ out.extend(component_sets.iter().map(|(k, _)| Id::ComponentSet(k)));
+ out.extend(styles.iter().map(|(k, _)| Id::Style(k)));
+ out
+}
+```
+
+Adjust import paths to whatever `fold` actually exports (`TableReader` etc. are re-exported from `fold::pipeline::terminal`); if `Bm25`'s reader type name differs, follow the source in `fold/src/pipeline/terminal/search/mod.rs`.
+
+- [ ] **Step 2: Write the failing tests** — `tests/sync.rs`:
+
+```rust
+mod common;
+
+use std::cell::Cell;
+use std::collections::BTreeSet;
+use std::rc::Rc;
+
+use figmog::flatten::flatten_file;
+use figmog::model::{Id, Rec};
+use figmog::store::{Churn, collect_sweepable, sync};
+use fold::pipeline::{Keyed, Map};
+
+/// Open a store whose pipeline is fronted by a delta probe: every push
+/// into the graph bumps the counter. Zero churn must mean zero pushes.
+macro_rules! open_probed {
+ ($path:expr, $counter:expr) => {{
+ let c = $counter.clone();
+ ::fold::stream::KeyedStream::::new(
+ $path,
+ Map::new(
+ move |d: &Keyed| {
+ c.set(c.get() + 1);
+ d.clone()
+ },
+ figmog::figmog_pipeline!(),
+ ),
+ )
+ }};
+}
+
+fn pull(
+ st: &mut fold::stream::KeyedStream>>,
+ fixture: &serde_json::Value,
+) -> Churn {
+ // NOTE: `impl Trait` in argument position works here because we only
+ // use write-path (upsert/remove) APIs; readers stay at the call site.
+ let flattened = flatten_file(fixture).unwrap();
+ let prior = BTreeSet::new(); // overridden by tests that need the sweep
+ sync(st, &prior, &flattened, 1_000)
+}
+
+#[test]
+fn initial_pull_populates_every_sink() {
+ let dir = tempfile::tempdir().unwrap();
+ let counter = Rc::new(Cell::new(0usize));
+ let mut st = open_probed!(dir.path().join("db"), counter);
+
+ let churn = pull(&mut st, &common::fixture_v1());
+ assert_eq!(churn, Churn { added: 18, changed: 0, removed: 0, unchanged: 0 });
+ // 18 records + 1 meta row, all fresh inserts -> 19 pushes
+ assert_eq!(counter.get(), 19);
+
+ st.rtx(|((nodes, children, text, instances_of, styled_by, bound_to, by_type),
+ components, component_sets, styles, _vars, _colls, meta)| {
+ assert_eq!(nodes.iter().count(), 12);
+ assert_eq!(nodes.get(&"1:2".to_string()).unwrap().name, "Title");
+
+ let mut kids = children.get(&"1:1".to_string());
+ kids.sort();
+ assert_eq!(kids, vec![(0, "1:2".to_string()), (1, "1:3".to_string())]);
+
+ let hits = text.search("garden", 5);
+ assert!(hits.iter().any(|h| h.val == "1:2"), "bm25 finds the title text");
+
+ assert_eq!(instances_of.search(&"2:2".to_string()), vec!["1:3".to_string()]);
+ assert_eq!(styled_by.search(&"S:2".to_string()), vec!["1:2".to_string()]);
+ assert_eq!(bound_to.search(&"VariableID:100".to_string()), vec!["1:1".to_string()]);
+
+ let mut texts = by_type.search(&"TEXT".to_string());
+ texts.sort();
+ assert_eq!(texts, vec!["1:2".to_string()]);
+
+ assert_eq!(components.iter().count(), 3);
+ assert_eq!(component_sets.iter().count(), 1);
+ assert_eq!(styles.iter().count(), 2);
+ let m = meta.get(&()).unwrap();
+ assert_eq!(m.version, "100");
+ assert_eq!(m.synced_at_unix_ms, 1_000);
+ });
+}
+
+#[test]
+fn identical_repull_causes_zero_churn() {
+ let dir = tempfile::tempdir().unwrap();
+ let counter = Rc::new(Cell::new(0usize));
+ let mut st = open_probed!(dir.path().join("db"), counter);
+
+ pull(&mut st, &common::fixture_v1());
+ counter.set(0);
+ let churn = pull(&mut st, &common::fixture_v1()); // same synced_at too
+ assert_eq!(churn, Churn { added: 0, changed: 0, removed: 0, unchanged: 18 });
+ assert_eq!(counter.get(), 0, "no delta may enter the graph on an identical re-pull");
+}
+
+#[test]
+fn reopen_resumes_persisted_state() {
+ let dir = tempfile::tempdir().unwrap();
+ let db = dir.path().join("db");
+ {
+ let mut st = figmog::open_store!(&db);
+ let flattened = flatten_file(&common::fixture_v1()).unwrap();
+ sync(&mut st, &BTreeSet::new(), &flattened, 1_000);
+ }
+ let st = figmog::open_store!(&db);
+ st.rtx(|((nodes, ..), _, _, _, _, _, _)| {
+ assert_eq!(nodes.iter().count(), 12);
+ });
+}
+```
+
+Adjust the destructuring/count numbers only if the fixture changes: 12 nodes + 3 components + 1 set + 2 styles = **18** records, +1 meta row.
+
+- [ ] **Step 3: Run to verify failure, then make it compile and pass**
+
+```bash
+cargo test -p figmog --test sync
+```
+Iterate until PASS. Likely friction points, in order: generic bounds on `sync` (`P: Push>` is all it needs), reader type paths, `Bm25::search` signature (`fn search(&self, q: &str, k: usize) -> Vec>` per the `search` example), tuple arity (root = 7 elements, node branch = 7 — both within fold's 16-tuple `Push`).
+
+- [ ] **Step 4: Full test run + clippy** — `cargo test -p figmog && cargo clippy -p figmog -- -D warnings` → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/store.rs examples/figmog/tests/sync.rs
+git commit -m "feat(figmog): pipeline, sync transaction, churn accounting"
+```
+
+---
+
+### Task 7: `store` — diff, sweep, atomicity (fixture v2)
+
+**Files:**
+- Modify: `examples/figmog/tests/common/mod.rs` (add `fixture_v2`)
+- Modify: `examples/figmog/tests/sync.rs` (add tests)
+
+**Interfaces:**
+- Consumes: Task 6's `sync`/`collect_sweepable`/probe macro.
+- Produces: `common::fixture_v2() -> Value` — v1 with: node `1:2` renamed `Title` → `Headline`; node `1:9` deleted; new TEXT node `1:4` (name `Subtitle`, characters `Planting season`) appended as third child of `1:1`; instance `1:3` `Size` property changed to `Small` and `componentId` to `2:3`; version `"101"`.
+
+- [ ] **Step 1: Implement `fixture_v2`** in `tests/common/mod.rs` — derive from v1 by mutation so the fixtures can't drift apart:
+
+```rust
+/// v1 plus: rename 1:2, delete 1:9, add 1:4, repoint instance 1:3 at the
+/// Small variant, bump version.
+pub fn fixture_v2() -> Value {
+ let mut v = fixture_v1();
+ v["version"] = json!("101");
+ v["lastModified"] = json!("2026-08-02T00:00:00Z");
+ let page1 = &mut v["document"]["children"][0];
+ // delete 1:9 (second child of the canvas)
+ page1["children"].as_array_mut().unwrap().remove(1);
+ let hero = &mut page1["children"][0];
+ hero["children"][0]["name"] = json!("Headline");
+ hero["children"][1]["componentId"] = json!("2:3");
+ hero["children"][1]["componentProperties"]["Size"]["value"] = json!("Small");
+ hero["children"][1]["componentProperties"]["State"]["value"] = json!("Hover");
+ hero["children"].as_array_mut().unwrap().push(json!({
+ "id": "1:4", "name": "Subtitle", "type": "TEXT",
+ "characters": "Planting season", "children": []
+ }));
+ v
+}
+```
+
+- [ ] **Step 2: Add the failing tests** to `tests/sync.rs`:
+
+```rust
+/// Pull v2 over v1 with the sweep enabled, capturing probe deltas.
+fn pull_with_sweep(
+ st: &mut fold::stream::KeyedStream>>,
+ fixture: &serde_json::Value,
+ prior: BTreeSet,
+ synced_at: u64,
+) -> Churn {
+ let flattened = flatten_file(fixture).unwrap();
+ sync(st, &prior, &flattened, synced_at)
+}
+
+#[test]
+fn v1_to_v2_minimal_churn_and_index_consistency() {
+ let dir = tempfile::tempdir().unwrap();
+ let counter = Rc::new(Cell::new(0usize));
+ let mut st = open_probed!(dir.path().join("db"), counter);
+ pull(&mut st, &common::fixture_v1());
+
+ let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| {
+ collect_sweepable(&nodes, &components, &component_sets, &styles)
+ });
+ counter.set(0);
+ let churn = pull_with_sweep(&mut st, &common::fixture_v2(), prior, 1_000);
+
+ // v2 has 18 records: 12 nodes (12 - 1:9 + 1:4) + 3 components + 1 set
+ // + 2 styles. changed: 1:2 (rename), 1:3 (variant repoint). added: 1:4.
+ // removed: 1:9. unchanged: 18 - 1 - 2 = 15 (meta row is not counted).
+ assert_eq!(churn, Churn { added: 1, changed: 2, removed: 1, unchanged: 15 });
+ // pushes: changed 2×2 + added 1 + removed 1 + meta retract/insert 2 = 8
+ assert_eq!(counter.get(), 8);
+
+ st.rtx(|((nodes, children, text, instances_of, _styled, _bound, by_type),
+ _c, _cs, _s, _v, _vc, meta)| {
+ // rename re-indexed in bm25
+ assert!(text.search("Headline", 5).iter().any(|h| h.val == "1:2"));
+ assert!(!text.search("Title", 5).iter().any(|h| h.val == "1:2"));
+ // deleted node gone everywhere
+ assert!(nodes.get(&"1:9".to_string()).is_none());
+ assert!(!by_type.search(&"RECTANGLE".to_string()).contains(&"1:9".to_string()));
+ let kids = children.get(&"0:1".to_string());
+ assert!(!kids.iter().any(|(_, id)| id == "1:9"));
+ // instance repoint moved the inverted index posting
+ assert_eq!(instances_of.search(&"2:2".to_string()), Vec::::new());
+ assert_eq!(instances_of.search(&"2:3".to_string()), vec!["1:3".to_string()]);
+ // new node present
+ assert_eq!(nodes.get(&"1:4".to_string()).unwrap().name, "Subtitle");
+ assert!(text.search("Planting", 5).iter().any(|h| h.val == "1:4"));
+ assert_eq!(meta.get(&()).unwrap().version, "101");
+ });
+}
+
+#[test]
+fn sweep_never_touches_variables() {
+ use figmog::model::{VariableCollectionRec, VariableRec};
+ let dir = tempfile::tempdir().unwrap();
+ let mut st = figmog::open_store!(dir.path().join("db"));
+ pull(&mut st, &common::fixture_v1());
+ // hand-insert an imported variable, then re-pull with a full sweep set
+ st.wtx(|tx| {
+ tx.upsert(
+ &Id::Variable("VariableID:100".into()),
+ &Rec::Variable(VariableRec {
+ id: "VariableID:100".into(),
+ name: "color/bg".into(),
+ resolved_type: "COLOR".into(),
+ collection_id: "VC:1".into(),
+ values_by_mode: vec![("M:1".into(), "{\"r\":0.06}".into())],
+ description: String::new(),
+ scopes: vec![],
+ }),
+ );
+ tx.upsert(
+ &Id::VariableCollection("VC:1".into()),
+ &Rec::VariableCollection(VariableCollectionRec {
+ id: "VC:1".into(),
+ name: "core".into(),
+ modes: vec![("M:1".into(), "light".into())],
+ default_mode_id: "M:1".into(),
+ }),
+ );
+ });
+ let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| {
+ collect_sweepable(&nodes, &components, &component_sets, &styles)
+ });
+ pull_with_sweep(&mut st, &common::fixture_v2(), prior, 2_000);
+ st.rtx(|(_, _, _, _, vars, colls, _)| {
+ assert!(vars.get(&"VariableID:100".to_string()).is_some());
+ assert!(colls.get(&"VC:1".to_string()).is_some());
+ });
+}
+
+#[test]
+fn panicking_transaction_rolls_back_entirely() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut st = figmog::open_store!(dir.path().join("db"));
+ pull(&mut st, &common::fixture_v1());
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ st.wtx(|tx| {
+ tx.upsert(
+ &Id::Node("9:9".into()),
+ &Rec::Node(figmog::model::NodeRec {
+ id: "9:9".into(),
+ parent_id: Some("0:1".into()),
+ child_index: 7,
+ page_id: "0:1".into(),
+ node_type: "FRAME".into(),
+ name: "doomed".into(),
+ visible: true,
+ text: None,
+ component_id: None,
+ component_properties: vec![],
+ property_definitions: None,
+ style_refs: vec![],
+ bound_variables: vec![],
+ abs_bounds: None,
+ raw: "{}".into(),
+ }),
+ );
+ panic!("mid-transaction failure");
+ })
+ }));
+ assert!(result.is_err());
+ st.rtx(|((nodes, ..), _, _, _, _, _, meta)| {
+ assert!(nodes.get(&"9:9".to_string()).is_none(), "aborted upsert must not persist");
+ assert_eq!(nodes.iter().count(), 12);
+ assert_eq!(meta.get(&()).unwrap().version, "100");
+ });
+}
+```
+
+- [ ] **Step 3: Run to verify failure, fix, pass** — `cargo test -p figmog --test sync`. The probe-count assertion (8) is the heart of the whole project — if it's off, find out which record churned unexpectedly (print `churn` and diff the flattens) rather than adjusting the number. Legitimate adjustment only if a fixture edit above changes the arithmetic, and then the comment must be recomputed too.
+
+- [ ] **Step 4: Full test run + clippy** → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/tests
+git commit -m "test(figmog): diff churn, sweep exemptions, rollback atomicity"
+```
+
+---
+
+### Task 8: `api.rs` — Figma HTTP client behind a trait
+
+**Files:**
+- Modify: `examples/figmog/src/api.rs`
+- Test: unit tests in the same file
+
+**Interfaces:**
+- Produces:
+ - `#[derive(Debug, thiserror::Error)] pub enum ApiError { RateLimited { retry_after: Duration }, Auth, Http { status: u16, msg: String }, Network(String), Parse(String) }`
+ - `pub struct FileMetaResp { pub name: String, pub last_touched_at: String }`
+ - `pub trait FigmaApi { fn file_meta(&self, key: &str) -> Result; fn file(&self, key: &str) -> Result; }`
+ - `pub struct UreqApi { … } ; UreqApi::new(token: String) ; UreqApi::with_base_url(token, base)`
+ - `pub(crate) fn parse_meta_response(v: &serde_json::Value) -> Result`
+
+- [ ] **Step 1: Write the failing tests**
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_meta_envelope() {
+ let v = serde_json::json!({
+ "file": {"name": "F", "last_touched_at": "2026-08-01T00:00:00Z", "folder_name": "x"}
+ });
+ let m = parse_meta_response(&v).unwrap();
+ assert_eq!(m.name, "F");
+ assert_eq!(m.last_touched_at, "2026-08-01T00:00:00Z");
+ }
+
+ #[test]
+ fn meta_missing_fields_is_parse_error() {
+ assert!(matches!(
+ parse_meta_response(&serde_json::json!({"file": {}})),
+ Err(ApiError::Parse(_))
+ ));
+ }
+
+ #[test]
+ fn error_from_status_maps_429_and_403() {
+ assert!(matches!(
+ error_from_status(429, Some("30"), "slow down".into()),
+ ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(30)
+ ));
+ // absent/garbage Retry-After falls back to 60s
+ assert!(matches!(
+ error_from_status(429, None, String::new()),
+ ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(60)
+ ));
+ assert!(matches!(error_from_status(403, None, String::new()), ApiError::Auth));
+ assert!(matches!(
+ error_from_status(500, None, "boom".into()),
+ ApiError::Http { status: 500, .. }
+ ));
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog api` → compile FAIL.
+
+- [ ] **Step 3: Implement**
+
+```rust
+//! Figma REST client. Everything network-facing sits behind [`FigmaApi`]
+//! so the rest of the crate is testable offline.
+
+use std::time::Duration;
+
+use serde_json::Value;
+
+/// Errors surfaced by [`FigmaApi`] implementations.
+#[derive(Debug, thiserror::Error)]
+pub enum ApiError {
+ #[error("rate limited; retry after {retry_after:?}")]
+ RateLimited { retry_after: Duration },
+ #[error("authentication failed — check FIGMA_TOKEN and file access")]
+ Auth,
+ #[error("figma returned {status}: {msg}")]
+ Http { status: u16, msg: String },
+ #[error("network error: {0}")]
+ Network(String),
+ #[error("unexpected response shape: {0}")]
+ Parse(String),
+}
+
+/// Subset of `GET /v1/files/:key/meta` figmog needs.
+#[derive(Debug, Clone, PartialEq)]
+pub struct FileMetaResp {
+ pub name: String,
+ /// "The UTC ISO 8601 time at which the file content was last modified."
+ pub last_touched_at: String,
+}
+
+/// The two calls figmog makes. `file_meta` is Tier 3 (cheap, poll it);
+/// `file` is Tier 1 (expensive, call only on change).
+pub trait FigmaApi {
+ fn file_meta(&self, key: &str) -> Result;
+ fn file(&self, key: &str) -> Result;
+}
+
+pub(crate) fn parse_meta_response(v: &Value) -> Result {
+ let file = v.get("file").ok_or_else(|| ApiError::Parse("no `file` object".into()))?;
+ let get = |k: &str| {
+ file.get(k)
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ .ok_or_else(|| ApiError::Parse(format!("meta missing `{k}`")))
+ };
+ Ok(FileMetaResp { name: get("name")?, last_touched_at: get("last_touched_at")? })
+}
+
+pub(crate) fn error_from_status(status: u16, retry_after: Option<&str>, msg: String) -> ApiError {
+ match status {
+ 429 => ApiError::RateLimited {
+ retry_after: Duration::from_secs(
+ retry_after.and_then(|s| s.trim().parse().ok()).unwrap_or(60),
+ ),
+ },
+ 401 | 403 => ApiError::Auth,
+ _ => ApiError::Http { status, msg },
+ }
+}
+
+/// Blocking `ureq` implementation against api.figma.com.
+pub struct UreqApi {
+ token: String,
+ base_url: String,
+}
+
+impl UreqApi {
+ pub fn new(token: String) -> Self {
+ Self::with_base_url(token, "https://api.figma.com".into())
+ }
+ /// `base_url` override for tests / proxies.
+ pub fn with_base_url(token: String, base_url: String) -> Self {
+ UreqApi { token, base_url }
+ }
+
+ fn get_json(&self, path: &str) -> Result {
+ let url = format!("{}{}", self.base_url, path);
+ match ureq::get(&url).set("X-Figma-Token", &self.token).call() {
+ Ok(resp) => resp
+ .into_json()
+ .map_err(|e| ApiError::Parse(e.to_string())),
+ Err(ureq::Error::Status(status, resp)) => {
+ let retry = resp.header("Retry-After").map(str::to_string);
+ let msg = resp.into_string().unwrap_or_default();
+ Err(error_from_status(status, retry.as_deref(), msg))
+ }
+ Err(e) => Err(ApiError::Network(e.to_string())),
+ }
+ }
+}
+
+impl FigmaApi for UreqApi {
+ fn file_meta(&self, key: &str) -> Result {
+ parse_meta_response(&self.get_json(&format!("/v1/files/{key}/meta"))?)
+ }
+ fn file(&self, key: &str) -> Result {
+ self.get_json(&format!("/v1/files/{key}"))
+ }
+}
+```
+
+- [ ] **Step 4: Run tests + clippy** → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/api.rs
+git commit -m "feat(figmog): figma api client behind a testable trait"
+```
+
+---
+
+### Task 9: `watch.rs` — change-detection state machine
+
+**Files:**
+- Modify: `examples/figmog/src/watch.rs`
+- Test: unit tests in the same file
+
+**Interfaces:**
+- Consumes: `api::{ApiError, FigmaApi, FileMetaResp}`.
+- Produces:
+ - `pub enum Tick { Unchanged, Changed { last_touched_at: String }, Wait { after: Duration } }`
+ - `pub struct Watcher { … } ; Watcher::new(last_seen: Option) ; fn tick(&mut self, api: &dyn FigmaApi, key: &str) -> Tick`
+ - Backoff contract: rate-limit waits use the server's `Retry-After`; network/HTTP failures start at 5s and double per consecutive failure, capped at 300s; any success resets the backoff.
+
+- [ ] **Step 1: Write the failing tests**
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::{ApiError, FigmaApi, FileMetaResp};
+ use std::cell::RefCell;
+ use std::time::Duration;
+
+ /// Scripted API: pops one response per call; panics if `file` is called.
+ struct Script(RefCell>>);
+ impl Script {
+ fn new(mut responses: Vec>) -> Self {
+ responses.reverse();
+ Script(RefCell::new(responses))
+ }
+ }
+ impl FigmaApi for Script {
+ fn file_meta(&self, _key: &str) -> Result {
+ self.0.borrow_mut().pop().expect("unexpected extra file_meta call")
+ }
+ fn file(&self, _key: &str) -> Result {
+ panic!("watcher must never fetch the file itself");
+ }
+ }
+
+ fn meta(t: &str) -> Result {
+ Ok(FileMetaResp { name: "F".into(), last_touched_at: t.into() })
+ }
+
+ #[test]
+ fn unchanged_then_changed() {
+ let api = Script::new(vec![meta("t1"), meta("t1"), meta("t2")]);
+ let mut w = Watcher::new(Some("t1".into()));
+ assert!(matches!(w.tick(&api, "k"), Tick::Unchanged));
+ assert!(matches!(w.tick(&api, "k"), Tick::Unchanged));
+ match w.tick(&api, "k") {
+ Tick::Changed { last_touched_at } => assert_eq!(last_touched_at, "t2"),
+ other => panic!("expected Changed, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn first_tick_with_no_history_is_changed() {
+ let api = Script::new(vec![meta("t1")]);
+ let mut w = Watcher::new(None);
+ assert!(matches!(w.tick(&api, "k"), Tick::Changed { .. }));
+ }
+
+ #[test]
+ fn rate_limit_uses_retry_after() {
+ let api = Script::new(vec![
+ Err(ApiError::RateLimited { retry_after: Duration::from_secs(30) }),
+ meta("t1"),
+ ]);
+ let mut w = Watcher::new(Some("t1".into()));
+ assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(30)));
+ assert!(matches!(w.tick(&api, "k"), Tick::Unchanged));
+ }
+
+ #[test]
+ fn failures_back_off_exponentially_and_reset_on_success() {
+ let api = Script::new(vec![
+ Err(ApiError::Network("down".into())),
+ Err(ApiError::Network("down".into())),
+ Err(ApiError::Network("down".into())),
+ meta("t1"),
+ Err(ApiError::Network("down".into())),
+ ]);
+ let mut w = Watcher::new(Some("t1".into()));
+ assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5)));
+ assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(10)));
+ assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(20)));
+ assert!(matches!(w.tick(&api, "k"), Tick::Unchanged));
+ assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5)));
+ }
+
+ #[test]
+ fn backoff_caps_at_five_minutes() {
+ let mut responses: Vec> =
+ (0..10).map(|_| Err(ApiError::Network("down".into()))).collect();
+ responses.push(meta("t1"));
+ let api = Script::new(responses);
+ let mut w = Watcher::new(Some("t1".into()));
+ let mut last = Duration::ZERO;
+ for _ in 0..10 {
+ match w.tick(&api, "k") {
+ Tick::Wait { after } => last = after,
+ other => panic!("expected Wait, got {other:?}"),
+ }
+ }
+ assert_eq!(last, Duration::from_secs(300));
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog watch` → compile FAIL.
+
+- [ ] **Step 3: Implement**
+
+```rust
+//! Polling change detection: a pure state machine the CLI loop drives.
+//! No sleeping, no clock — callers act on the returned [`Tick`].
+
+use std::time::Duration;
+
+use crate::api::{ApiError, FigmaApi};
+
+const BACKOFF_START: Duration = Duration::from_secs(5);
+const BACKOFF_CAP: Duration = Duration::from_secs(300);
+
+/// Outcome of one poll.
+#[derive(Debug)]
+pub enum Tick {
+ /// File unchanged since the last seen `last_touched_at`.
+ Unchanged,
+ /// File changed — caller should pull. Carries the new watermark.
+ Changed { last_touched_at: String },
+ /// Transient failure or rate limit — caller should sleep `after`
+ /// (instead of its normal interval), then tick again.
+ Wait { after: Duration },
+}
+
+/// Tracks the last seen content-modification time and failure backoff.
+pub struct Watcher {
+ last_seen: Option,
+ backoff: Duration,
+}
+
+impl Watcher {
+ /// `last_seen`: the stored `FileMeta.last_modified`, if any. A spurious
+ /// mismatch only costs one pull that produces zero churn.
+ pub fn new(last_seen: Option) -> Self {
+ Watcher { last_seen, backoff: BACKOFF_START }
+ }
+
+ pub fn tick(&mut self, api: &dyn FigmaApi, key: &str) -> Tick {
+ match api.file_meta(key) {
+ Ok(meta) => {
+ self.backoff = BACKOFF_START;
+ if self.last_seen.as_deref() == Some(meta.last_touched_at.as_str()) {
+ Tick::Unchanged
+ } else {
+ self.last_seen = Some(meta.last_touched_at.clone());
+ Tick::Changed { last_touched_at: meta.last_touched_at }
+ }
+ }
+ Err(ApiError::RateLimited { retry_after }) => Tick::Wait { after: retry_after },
+ Err(_) => {
+ let after = self.backoff;
+ self.backoff = (self.backoff * 2).min(BACKOFF_CAP);
+ Tick::Wait { after }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests + clippy** → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/watch.rs
+git commit -m "feat(figmog): watch state machine with retry-after and backoff"
+```
+
+---
+
+### Task 10: `vars.rs` — variables import parsing
+
+**Files:**
+- Modify: `examples/figmog/src/vars.rs`
+- Create: `examples/figmog/tests/fixtures/variables-export.json`
+- Create: `examples/figmog/tests/vars.rs`
+
+**Interfaces:**
+- Consumes: `model::*`.
+- Produces:
+ - `#[derive(Debug, thiserror::Error)] pub enum ImportError { #[error("unrecognized export shape: {0}")] Shape(String) }`
+ - `pub fn parse_variables_export(v: &serde_json::Value) -> Result, ImportError>` — accepts the Enterprise REST `variables/local` shape (`{meta: {variables, variableCollections}}`) and the bare `{variables, variableCollections}` shape (plugin-console export). Output sorted: collections first (by id), then variables (by id).
+
+- [ ] **Step 1: Write the fixture** — `tests/fixtures/variables-export.json` (synthetic, REST-shaped):
+
+```json
+{
+ "status": 200,
+ "error": false,
+ "meta": {
+ "variables": {
+ "VariableID:100": {
+ "id": "VariableID:100",
+ "name": "color/surface/primary",
+ "resolvedType": "COLOR",
+ "variableCollectionId": "VariableCollectionId:1",
+ "valuesByMode": {
+ "1:0": { "r": 0.06, "g": 0.13, "b": 0.2, "a": 1.0 },
+ "1:1": { "type": "VARIABLE_ALIAS", "id": "VariableID:101" }
+ },
+ "description": "primary surface",
+ "scopes": ["FRAME_FILL", "SHAPE_FILL"]
+ },
+ "VariableID:101": {
+ "id": "VariableID:101",
+ "name": "color/base/ink",
+ "resolvedType": "COLOR",
+ "variableCollectionId": "VariableCollectionId:1",
+ "valuesByMode": { "1:0": { "r": 0.9, "g": 0.9, "b": 0.9, "a": 1.0 },
+ "1:1": { "r": 0.1, "g": 0.1, "b": 0.1, "a": 1.0 } },
+ "description": "",
+ "scopes": ["ALL_SCOPES"]
+ },
+ "VariableID:200": {
+ "id": "VariableID:200",
+ "name": "space/md",
+ "resolvedType": "FLOAT",
+ "variableCollectionId": "VariableCollectionId:2",
+ "valuesByMode": { "2:0": 16.0 },
+ "description": "",
+ "scopes": ["GAP"]
+ }
+ },
+ "variableCollections": {
+ "VariableCollectionId:1": {
+ "id": "VariableCollectionId:1",
+ "name": "colors",
+ "modes": [ {"modeId": "1:0", "name": "light"}, {"modeId": "1:1", "name": "dark"} ],
+ "defaultModeId": "1:0"
+ },
+ "VariableCollectionId:2": {
+ "id": "VariableCollectionId:2",
+ "name": "spacing",
+ "modes": [ {"modeId": "2:0", "name": "default"} ],
+ "defaultModeId": "2:0"
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Write the failing tests** — `tests/vars.rs`:
+
+```rust
+mod common;
+
+use figmog::model::{Id, Rec};
+use figmog::vars::parse_variables_export;
+
+fn export() -> serde_json::Value {
+ serde_json::from_str(include_str!("fixtures/variables-export.json")).unwrap()
+}
+
+#[test]
+fn parses_rest_shape() {
+ let recs = parse_variables_export(&export()).unwrap();
+ // 2 collections then 3 variables, sorted by id
+ assert_eq!(recs.len(), 5);
+ assert!(matches!(&recs[0].0, Id::VariableCollection(id) if id == "VariableCollectionId:1"));
+ let Rec::VariableCollection(c) = &recs[0].1 else { panic!() };
+ assert_eq!(c.modes, vec![("1:0".to_string(), "light".to_string()), ("1:1".to_string(), "dark".to_string())]);
+ assert_eq!(c.default_mode_id, "1:0");
+
+ let Rec::Variable(v) = &recs[2].1 else { panic!() };
+ assert_eq!(v.id, "VariableID:100");
+ assert_eq!(v.resolved_type, "COLOR");
+ assert_eq!(v.collection_id, "VariableCollectionId:1");
+ // values canonical JSON, sorted by mode id; alias kept as-is
+ assert_eq!(v.values_by_mode[0].0, "1:0");
+ assert!(v.values_by_mode[1].1.contains("VARIABLE_ALIAS"));
+ assert_eq!(v.scopes, vec!["FRAME_FILL", "SHAPE_FILL"]);
+}
+
+#[test]
+fn accepts_bare_shape_and_is_deterministic() {
+ let bare = export()["meta"].clone();
+ let a = parse_variables_export(&bare).unwrap();
+ let b = parse_variables_export(&export()).unwrap();
+ assert_eq!(
+ postcard::to_allocvec(&a).unwrap(),
+ postcard::to_allocvec(&b).unwrap(),
+ "both shapes produce byte-identical records"
+ );
+}
+
+#[test]
+fn garbage_is_a_shape_error() {
+ assert!(parse_variables_export(&serde_json::json!({"nope": 1})).is_err());
+ assert!(parse_variables_export(&serde_json::json!(null)).is_err());
+}
+```
+
+- [ ] **Step 3: Run to verify failure** — `cargo test -p figmog --test vars` → compile FAIL.
+
+- [ ] **Step 4: Implement** (`src/vars.rs`; inference lands in Task 11 in this same file):
+
+```rust
+//! Variables: authoritative import parsing (this module also hosts the
+//! free-plan inference in `infer`).
+
+use std::collections::BTreeMap;
+
+use serde_json::Value;
+
+use crate::model::{Id, Rec, VariableCollectionRec, VariableRec};
+
+#[derive(Debug, thiserror::Error)]
+pub enum ImportError {
+ #[error("unrecognized variables export shape: {0}")]
+ Shape(String),
+}
+
+/// Parse a variables export: either the Enterprise REST `variables/local`
+/// response (`{meta: {variables, variableCollections}}`) or the bare
+/// object a plugin-console export produces.
+pub fn parse_variables_export(v: &Value) -> Result, ImportError> {
+ let root = v.get("meta").unwrap_or(v);
+ let variables = root
+ .get("variables")
+ .and_then(Value::as_object)
+ .ok_or_else(|| ImportError::Shape("missing `variables` object".into()))?;
+ let collections = root
+ .get("variableCollections")
+ .and_then(Value::as_object)
+ .ok_or_else(|| ImportError::Shape("missing `variableCollections` object".into()))?;
+
+ let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
+
+ let mut recs = Vec::new();
+ let sorted: BTreeMap<_, _> = collections.iter().collect();
+ for (id, c) in sorted {
+ let mut modes: Vec<(String, String)> = c
+ .get("modes")
+ .and_then(Value::as_array)
+ .map(|ms| ms.iter().map(|m| (s(m, "modeId"), s(m, "name"))).collect())
+ .unwrap_or_default();
+ modes.sort();
+ recs.push((
+ Id::VariableCollection(id.clone()),
+ Rec::VariableCollection(VariableCollectionRec {
+ id: id.clone(),
+ name: s(c, "name"),
+ modes,
+ default_mode_id: s(c, "defaultModeId"),
+ }),
+ ));
+ }
+ let sorted: BTreeMap<_, _> = variables.iter().collect();
+ for (id, var) in sorted {
+ let mut values_by_mode: Vec<(String, String)> = var
+ .get("valuesByMode")
+ .and_then(Value::as_object)
+ .map(|m| {
+ m.iter()
+ .map(|(mode, val)| {
+ (mode.clone(), serde_json::to_string(val).expect("Value serializes"))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ values_by_mode.sort();
+ let scopes: Vec = var
+ .get("scopes")
+ .and_then(Value::as_array)
+ .map(|xs| xs.iter().filter_map(Value::as_str).map(str::to_string).collect())
+ .unwrap_or_default();
+ recs.push((
+ Id::Variable(id.clone()),
+ Rec::Variable(VariableRec {
+ id: id.clone(),
+ name: s(var, "name"),
+ resolved_type: s(var, "resolvedType"),
+ collection_id: s(var, "variableCollectionId"),
+ values_by_mode,
+ description: s(var, "description"),
+ scopes,
+ }),
+ ));
+ }
+ Ok(recs)
+}
+```
+
+- [ ] **Step 5: Run tests + clippy** → PASS. Commit:
+
+```bash
+git add examples/figmog/src/vars.rs examples/figmog/tests/vars.rs examples/figmog/tests/fixtures
+git commit -m "feat(figmog): variables export parsing (REST and plugin shapes)"
+```
+
+---
+
+### Task 11: `vars.rs` — free-plan inference + style values
+
+**Files:**
+- Modify: `examples/figmog/src/vars.rs`
+- Modify: `examples/figmog/tests/vars.rs` (add tests)
+
+**Interfaces:**
+- Consumes: `NodeRec.bound_variables` pointers (Task 5 contract: pointer addresses the resolved value), `NodeRec.raw`.
+- Produces:
+ - `#[derive(Debug, Serialize)] pub struct VarUsage { pub variable_id: String, pub sites: Vec<(String, String)>, pub observed: Vec }` — sites are (node_id, pointer), sorted; observed values are deduped canonical JSON, sorted.
+ - `pub fn infer_from_nodes<'a>(nodes: impl Iterator- ) -> Vec
` — iterator-based so it works from any reader or test vector; output sorted by variable id.
+ - `pub fn style_value_from_consumer(style_type: &str, consumer_raw: &str) -> Option` — TEXT→`/style`, FILL→`/fills`, EFFECT→`/effects`, GRID→`/layoutGrids`.
+
+- [ ] **Step 1: Add the failing tests** to `tests/vars.rs`:
+
+```rust
+use figmog::flatten::flatten_file;
+use figmog::vars::{infer_from_nodes, style_value_from_consumer};
+
+#[test]
+fn infers_values_and_sites_from_fixture() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let nodes: Vec = out
+ .recs
+ .iter()
+ .filter_map(|(_, r)| match r { Rec::Node(n) => Some(n.clone()), _ => None })
+ .collect();
+ let usages = infer_from_nodes(nodes.iter());
+
+ assert_eq!(usages.len(), 2, "two distinct variables bound in fixture");
+ let color = usages.iter().find(|u| u.variable_id == "VariableID:100").unwrap();
+ assert_eq!(color.sites, vec![("1:1".to_string(), "/fills/0/color".to_string())]);
+ let observed: serde_json::Value = serde_json::from_str(&color.observed[0]).unwrap();
+ assert_eq!(observed["r"], 0.06);
+
+ let pad = usages.iter().find(|u| u.variable_id == "VariableID:200").unwrap();
+ assert_eq!(pad.observed, vec!["16.0".to_string()]);
+}
+
+#[test]
+fn style_values_come_from_consumers() {
+ let out = flatten_file(&common::fixture_v1()).unwrap();
+ let title = out.recs.iter().find_map(|(k, r)| match (k, r) {
+ (Id::Node(id), Rec::Node(n)) if id == "1:2" => Some(n.clone()),
+ _ => None,
+ }).unwrap();
+ let v = style_value_from_consumer("TEXT", &title.raw).unwrap();
+ assert_eq!(v["fontSize"], 32.0);
+ assert!(style_value_from_consumer("FILL", &title.raw).is_none(), "no fills on the text node");
+}
+```
+
+Note on `"16.0"`: `serde_json` renders the fixture's `16.0` float back as `16.0`. If the implementation observes `16` instead, the fixture number parsed as an integer — keep the fixture value a float (as Figma emits) and assert what `serde_json::to_string` actually produces; the test value may be adjusted to the observed canonical form **once**, with a comment.
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test vars` → compile FAIL.
+
+- [ ] **Step 3: Implement** (append to `src/vars.rs`):
+
+```rust
+use std::collections::BTreeMap;
+
+use serde::Serialize;
+
+use crate::model::NodeRec;
+
+/// Everything known about one variable from its usage sites alone.
+#[derive(Debug, Serialize)]
+pub struct VarUsage {
+ pub variable_id: String,
+ /// (node_id, json-pointer of the bound property), sorted.
+ pub sites: Vec<(String, String)>,
+ /// Distinct resolved values observed at those sites (canonical JSON),
+ /// sorted. Usually one value; more indicates multi-mode usage.
+ pub observed: Vec,
+}
+
+/// Free-plan inference: fold every node's variable bindings into per-variable
+/// usage + observed resolved values (the concrete values Figma bakes in
+/// next to each binding — default-mode values unless a frame overrides its
+/// mode).
+pub fn infer_from_nodes<'a>(nodes: impl Iterator- ) -> Vec
{
+ let mut by_var: BTreeMap, Vec)> = BTreeMap::new();
+ for node in nodes {
+ let raw: serde_json::Value = match serde_json::from_str(&node.raw) {
+ Ok(v) => v,
+ Err(_) => continue,
+ };
+ for (pointer, var_id) in &node.bound_variables {
+ let entry = by_var.entry(var_id.clone()).or_default();
+ entry.0.push((node.id.clone(), pointer.clone()));
+ if let Some(v) = raw.pointer(pointer) {
+ entry.1.push(serde_json::to_string(v).expect("Value serializes"));
+ }
+ }
+ }
+ by_var
+ .into_iter()
+ .map(|(variable_id, (mut sites, mut observed))| {
+ sites.sort();
+ observed.sort();
+ observed.dedup();
+ VarUsage { variable_id, sites, observed }
+ })
+ .collect()
+}
+
+/// Derive a style's definition from one consumer node's raw JSON.
+/// Style definitions are not in the file JSON; consumers carry the
+/// resolved properties.
+pub fn style_value_from_consumer(style_type: &str, consumer_raw: &str) -> Option {
+ let raw: serde_json::Value = serde_json::from_str(consumer_raw).ok()?;
+ let pointer = match style_type {
+ "TEXT" => "/style",
+ "FILL" => "/fills",
+ "EFFECT" => "/effects",
+ "GRID" => "/layoutGrids",
+ _ => return None,
+ };
+ raw.pointer(pointer).cloned()
+}
+```
+
+- [ ] **Step 4: Run tests + clippy** → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/vars.rs examples/figmog/tests/vars.rs
+git commit -m "feat(figmog): variable inference and style value derivation"
+```
+
+---
+
+### Task 12: CLI part 1 — engine commands + core reads
+
+**Files:**
+- Modify: `examples/figmog/src/cli.rs` (replace the stub)
+- Create: `examples/figmog/tests/cli.rs`
+
+**Interfaces:**
+- Consumes: everything above.
+- Produces: `pub fn run() -> i32`; subcommands `pull` (with `--from-file`, `--fresh`), `status`, `pages`, `tree`, `get`, `find`; global `--json` and `--db`; config file `.figmog/current` holding the last file key; DB at `.figmog//db`. Remaining subcommands print a "not yet implemented" error (exit 2) until Task 13.
+- Structure: `enum Cmd` (clap derive) + one `dispatch` that opens the store via `open_store!` once and calls per-command functions taking concrete reader references. Human output goes through small `fn`s; `--json` serializes explicit `serde_json::json!` structures — never `Debug` formatting.
+
+- [ ] **Step 1: Write the failing CLI smoke tests** — `tests/cli.rs`:
+
+```rust
+mod common;
+
+use assert_cmd::Command;
+
+/// Materialize fixture_v1 into a DB via `pull --from-file` and return the
+/// (tempdir, db-arg) pair every read command needs.
+fn fixture_db() -> (tempfile::TempDir, String) {
+ let dir = tempfile::tempdir().unwrap();
+ let response = dir.path().join("resp.json");
+ std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap();
+ let db = dir.path().join("db").display().to_string();
+ Command::cargo_bin("figmog")
+ .unwrap()
+ .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db])
+ .assert()
+ .success();
+ (dir, db)
+}
+
+#[test]
+fn pull_from_file_reports_churn_and_is_idempotent() {
+ let dir = tempfile::tempdir().unwrap();
+ let response = dir.path().join("resp.json");
+ std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap();
+ let db = dir.path().join("db").display().to_string();
+
+ let out = Command::cargo_bin("figmog").unwrap()
+ .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"])
+ .assert().success();
+ let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap();
+ assert_eq!(v["added"], 18);
+
+ let out = Command::cargo_bin("figmog").unwrap()
+ .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"])
+ .assert().success();
+ let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap();
+ assert_eq!(v["unchanged"], 18);
+ assert_eq!(v["added"], 0);
+}
+
+#[test]
+fn status_pages_tree_get_find() {
+ let (_dir, db) = fixture_db();
+ let run = |args: &[&str]| {
+ let out = Command::cargo_bin("figmog").unwrap()
+ .args(args).args(["--db", &db, "--json"])
+ .assert().success();
+ serde_json::from_slice::(&out.get_output().stdout).unwrap()
+ };
+
+ let status = run(&["status"]);
+ assert_eq!(status["name"], "Fixture");
+ assert_eq!(status["version"], "100");
+ assert_eq!(status["nodes"], 12);
+
+ let pages = run(&["pages"]);
+ assert_eq!(pages.as_array().unwrap().len(), 3);
+ assert_eq!(pages[0]["id"], "0:1");
+ assert_eq!(pages[0]["name"], "Page 1");
+
+ let tree = run(&["tree", "1:1"]);
+ let kids = tree["children"].as_array().unwrap();
+ assert_eq!(kids.len(), 2);
+ assert_eq!(kids[0]["id"], "1:2"); // numeric child order
+
+ // node-id normalization: URL form accepted
+ let get = run(&["get", "1-2"]);
+ assert_eq!(get["name"], "Title");
+ assert_eq!(get["characters"], "Welcome to the garden");
+
+ let texts = run(&["find", "--type", "TEXT"]);
+ assert_eq!(texts.as_array().unwrap().len(), 1);
+ assert_eq!(texts[0]["id"], "1:2");
+
+ let on_page = run(&["find", "--type", "COMPONENT", "--page", "0:2"]);
+ assert_eq!(on_page.as_array().unwrap().len(), 3); // 2:2, 2:3, 3:1
+}
+
+#[test]
+fn get_unknown_node_fails_cleanly() {
+ let (_dir, db) = fixture_db();
+ Command::cargo_bin("figmog").unwrap()
+ .args(["get", "99:99", "--db", &db])
+ .assert()
+ .failure()
+ .code(1);
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test cli` → FAIL (stub exits 2).
+
+- [ ] **Step 3: Implement `cli.rs`.** Full skeleton — the shape matters more than the printing details:
+
+```rust
+//! Command-line surface. Read commands never touch the network: they open
+//! the local store and read one snapshot.
+
+use std::collections::BTreeSet;
+use std::path::PathBuf;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use clap::{Parser, Subcommand};
+use serde_json::{Value, json};
+
+use crate::api::{FigmaApi, UreqApi};
+use crate::flatten::flatten_file;
+use crate::ident::{normalize_node_id, parse_file_ref};
+use crate::model::{FileMeta, Id, NodeRec, Rec};
+use crate::store::{Churn, collect_sweepable, sync};
+use crate::watch::{Tick, Watcher};
+
+#[derive(Parser)]
+#[command(name = "figmog", about = "fold-backed local mirror of a Figma file")]
+struct Cli {
+ /// Emit machine-readable JSON on stdout.
+ #[arg(long, global = true)]
+ json: bool,
+ /// Store directory (default: .figmog//db).
+ #[arg(long, global = true)]
+ db: Option,
+ #[command(subcommand)]
+ cmd: Cmd,
+}
+
+#[derive(Subcommand)]
+enum Cmd {
+ /// Fetch the file (or read a saved response) and sync the mirror.
+ Pull {
+ /// File key or figma.com URL. Optional after the first pull.
+ file: Option,
+ /// Ingest a saved GET /v1/files/:key response instead of the network.
+ #[arg(long)]
+ from_file: Option,
+ /// Wipe the store and rebuild from scratch.
+ #[arg(long)]
+ fresh: bool,
+ },
+ /// Poll for changes and pull automatically.
+ Watch {
+ file: Option,
+ /// Poll interval in seconds.
+ #[arg(long, default_value = "10")]
+ interval: u64,
+ },
+ /// File name, version, last modified, node count.
+ Status,
+ /// List pages.
+ Pages,
+ /// Subtree outline (default: whole document).
+ Tree { id: Option, #[arg(long)] depth: Option },
+ /// Full raw JSON of one node.
+ Get { id: String, #[arg(long)] children: bool },
+ /// Nodes by type, optionally within one page.
+ Find { #[arg(long = "type")] node_type: String, #[arg(long)] page: Option },
+ /// BM25 search over layer names and text content.
+ Search { query: String, #[arg(short = 'n', long, default_value = "10")] limit: usize },
+ /// Instances of a component (by node id, key, or name).
+ Instances { target: String },
+ /// Design-system inventory: sets, variant axes, standalone components.
+ Components,
+ /// Styles with usage counts; --values derives definitions from consumers.
+ Styles { #[arg(long = "type")] style_type: Option, #[arg(long)] values: bool },
+ /// Nodes using a style id or bound to a variable id.
+ Uses { id: String },
+ /// Variables: authoritative if imported, else inferred from bindings.
+ Vars { id: Option },
+ /// Import a variables export (REST or plugin-console shape).
+ ImportVariables { path: PathBuf },
+}
+
+pub fn run() -> i32 {
+ let cli = Cli::parse();
+ match dispatch(cli) {
+ Ok(()) => 0,
+ Err(e) => {
+ eprintln!("figmog: {e}");
+ 1
+ }
+ }
+}
+
+fn dispatch(cli: Cli) -> Result<(), String> {
+ let db = resolve_db(&cli)?;
+ match cli.cmd {
+ Cmd::Pull { file, from_file, fresh } => cmd_pull(&db, file, from_file, fresh, cli.json),
+ Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json),
+ other => {
+ let mut st = crate::open_store!(&db.path);
+ store_dispatch(&mut st, other, cli.json) // ImportVariables writes; the rest read
+ }
+ }
+}
+```
+
+Key implementation points (write them exactly once, in this file):
+
+- **DB/config resolution** (`resolve_db`): `--db` wins; else read `.figmog/current` for the key → `.figmog//db`; `pull`/`watch` with an explicit file ref call `parse_file_ref`, write `.figmog/current`, and use `.figmog//db`. A read command with no `--db` and no config is an error: `"no mirror here — run `figmog pull ` first"`. Struct: `struct Db { path: PathBuf, key: Option }`.
+- **`cmd_pull`**: obtain the response `Value` from `--from-file` (read + `serde_json::from_str`) or `UreqApi::new(token).file(&key)` where token comes from `FIGMA_TOKEN` (`std::env::var`; missing token with a network pull is an error mentioning `FIGMA_TOKEN`). `--fresh`: `std::fs::remove_dir_all(&db.path).ok()` first. Then: `flatten_file` → open store → `collect_sweepable` inside `rtx` → `sync(&mut st, &prior, &flattened, now_ms())` → print `Churn` (json: `serde_json::to_string(&churn)`; human: `synced v: +A ~C -R (=U unchanged)`).
+- **`cmd_watch`**: initial pull if `status` has no meta row; then loop `Watcher::new(stored_last_modified)` → `tick(&api, &key)`; `Changed` → same pull path; `Wait { after }` → `std::thread::sleep(after)`; else sleep `interval`. Ctrl-C exits the process (no handler needed).
+- **`store_dispatch`**: handles `ImportVariables` with a `wtx` (Task 13); every other command runs one `st.rtx(|readers| …)` with the Task 6 destructuring, matching the command to a `cmd_*` function that takes only the readers it needs. Task 12 implements `Status`, `Pages`, `Tree`, `Get`, `Find` and returns `Err("not yet implemented: ".into())` for the Task 13 set.
+- **`cmd_pages`**: `children.get(&root)` where root is the node with `parent_id == None` — simpler: `by_type.search(&"CANVAS".to_string())`, sort ids, look up names in `nodes`, sort by (child_index) via each node's `child_index`. Output `[{id, name}]` sorted by child_index.
+- **`cmd_tree`**: resolve start id (`normalize_node_id`, default = the DOCUMENT node, found via `by_type.search(&"DOCUMENT".to_string())`); recursive descent over `children.get(id)` sorting each level by the `u32` index; depth limit honored; JSON shape `{id, name, type, children: […]}`; human shape two-space indented `name [TYPE] id` lines.
+- **`cmd_get`**: `nodes.get(&normalize_node_id(&id))` → parse `raw` to `Value`; if `--children`, attach `children: [{id, name, type}]` summaries; print pretty JSON in both modes (it *is* JSON); unknown id → `Err(format!("no node {id} in the mirror"))`.
+- **`cmd_find`**: `by_type.search(&node_type)`, look up each node, optional `page` filter on `page_id` (normalized), sort by id, print `[{id, name, page_id}]`.
+- **`now_ms()`**: `SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64` — the CLI is the wall-clock boundary; tests bypass it by driving `sync` directly.
+
+- [ ] **Step 4: Run the CLI tests, fix, pass**
+
+```bash
+cargo test -p figmog --test cli
+```
+Watch for: `assert_cmd` builds the real binary — `Command::cargo_bin("figmog")`; JSON output must go to stdout only (churn summary included), human/diagnostic text to stderr.
+
+- [ ] **Step 5: Full test run + clippy, commit**
+
+```bash
+cargo test -p figmog && cargo clippy -p figmog -- -D warnings
+git add examples/figmog/src/cli.rs examples/figmog/src/main.rs examples/figmog/tests/cli.rs
+git commit -m "feat(figmog): CLI engine commands and core reads (pull/status/pages/tree/get/find)"
+```
+
+---
+
+### Task 13: CLI part 2 — search & design-system commands + watch wiring
+
+**Files:**
+- Modify: `examples/figmog/src/cli.rs`
+- Modify: `examples/figmog/tests/cli.rs` (add tests)
+
+**Interfaces:**
+- Consumes: Task 6 readers, Task 10/11 `vars` functions.
+- Produces: working `search`, `instances`, `components`, `styles`, `uses`, `vars`, `import-variables`; `watch` loop wired (its state machine is already tested — no network test here).
+
+- [ ] **Step 1: Add the failing tests** to `tests/cli.rs`:
+
+```rust
+#[test]
+fn search_instances_components_styles_uses_vars() {
+ let (_dir, db) = fixture_db();
+ let run = |args: &[&str]| {
+ let out = Command::cargo_bin("figmog").unwrap()
+ .args(args).args(["--db", &db, "--json"])
+ .assert().success();
+ serde_json::from_slice::(&out.get_output().stdout).unwrap()
+ };
+
+ let hits = run(&["search", "garden"]);
+ assert_eq!(hits[0]["id"], "1:2");
+ assert!(hits[0]["score"].as_f64().unwrap() > 0.0);
+
+ // by node id, by key, by set name (=> all variants' instances)
+ for target in ["2:2", "key22", "Button"] {
+ let inst = run(&["instances", target]);
+ assert_eq!(inst[0]["id"], "1:3", "target={target}");
+ }
+
+ let comps = run(&["components"]);
+ let sets = comps["sets"].as_array().unwrap();
+ assert_eq!(sets.len(), 1);
+ assert_eq!(sets[0]["name"], "Button");
+ assert_eq!(sets[0]["variants"].as_array().unwrap().len(), 2);
+ let axes = &sets[0]["property_definitions"];
+ assert_eq!(axes["Size"]["variantOptions"], serde_json::json!(["Large", "Small"]));
+ assert_eq!(comps["components"].as_array().unwrap().len(), 1); // standalone only
+ assert_eq!(comps["components"][0]["name"], "IconStar");
+
+ let styles = run(&["styles"]);
+ assert_eq!(styles.as_array().unwrap().len(), 2);
+ assert_eq!(styles[0]["style_id"], "S:1");
+ assert_eq!(styles[0]["uses"], 1);
+
+ let styles = run(&["styles", "--values"]);
+ assert_eq!(styles[1]["value"]["fontSize"], 32.0); // S:2 from consumer 1:2
+
+ let uses = run(&["uses", "S:1"]);
+ assert_eq!(uses[0]["id"], "1:1");
+ let uses = run(&["uses", "VariableID:100"]);
+ assert_eq!(uses[0]["id"], "1:1");
+
+ let vars = run(&["vars"]);
+ let arr = vars.as_array().unwrap();
+ assert_eq!(arr.len(), 2);
+ assert_eq!(arr[0]["variable_id"], "VariableID:100");
+ assert_eq!(arr[0]["source"], "inferred");
+}
+
+#[test]
+fn import_variables_upgrades_vars_to_authoritative() {
+ let (dir, db) = fixture_db();
+ let export = dir.path().join("vars.json");
+ std::fs::write(&export, include_str!("fixtures/variables-export.json")).unwrap();
+
+ Command::cargo_bin("figmog").unwrap()
+ .args(["import-variables", export.to_str().unwrap(), "--db", &db])
+ .assert().success();
+
+ let out = Command::cargo_bin("figmog").unwrap()
+ .args(["vars", "--db", &db, "--json"])
+ .assert().success();
+ let vars: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap();
+ let v100 = vars.as_array().unwrap().iter()
+ .find(|v| v["variable_id"] == "VariableID:100").unwrap();
+ assert_eq!(v100["source"], "imported");
+ assert_eq!(v100["name"], "color/surface/primary");
+ assert_eq!(v100["collection"], "colors");
+ assert_eq!(v100["values_by_mode"]["light"]["r"], 0.06);
+ // inference detail still present alongside
+ assert_eq!(v100["sites"][0][0], "1:1");
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test cli` → FAIL (not-yet-implemented errors).
+
+- [ ] **Step 3: Implement the remaining `cmd_*` functions:**
+
+- **`cmd_search`**: `text.search(&query, limit)` → for each hit look up `nodes.get(&hit.val)` → `[{id, score, type, name, page_id, snippet}]` where snippet = first 80 chars of `text` field if present. Keep BM25's own ranking order (it is deterministic); do not re-sort.
+- **`cmd_instances`**: resolve `target` → component node ids: (1) if `components` table has the id → itself; (2) scan `components.iter()` for `key == target` → that node id; (3) scan `component_sets.iter()` for `name == target` → all components whose `component_set_id` is the set's node id; else scan `components.iter()` for `name == target`. Union → for each, `instances_of.search(&component_id)` → look up nodes, sort by id, print `[{id, name, page_id, component_id}]`. Ambiguous name matching >1 target class is fine — union them.
+- **`cmd_components`**: iterate `component_sets` table sorted; for each set: its `NodeRec.property_definitions` (parse to `Value`), variants = components with matching `component_set_id` sorted by node id (`[{node_id, name, key}]`). Standalone components = those with `component_set_id == None`. Output `{sets: […], components: […]}`.
+- **`cmd_styles`**: iterate `styles` table sorted by style id; optional `--type` filter (case-insensitive vs `style_type`); uses count = `styled_by.search(&style_id).len()`; with `--values`: first consumer sorted by node id → `vars::style_value_from_consumer(&style_type, &consumer.raw)` → include as `value` (null if none).
+- **`cmd_uses`**: try `styled_by.search(&id)`; if empty try `bound_to.search(&id)`; look up nodes, sort, `[{id, name, page_id}]`.
+- **`cmd_vars`**: inferred = `vars::infer_from_nodes(nodes.iter().map(|(_, n)| n))` — note `TableReader::iter` yields owned pairs; collect first. For each usage, if `variables` table has the id → merge: `source: "imported"`, `name`, `resolved_type`, `collection` (name via `variable_collections` table), `values_by_mode` re-keyed by mode *name* (fall back to mode id when the collection is unknown), plus `sites`/`observed`. Else `source: "inferred"` with `sites`/`observed` only. Also include imported variables that have **no** binding sites (they exist in the table but not in usages). Optional positional `id` filters to one variable. Sort by variable id.
+- **`cmd_import_variables`**: read file → `vars::parse_variables_export` → one `st.wtx` upserting every rec (these are `Id::Variable`/`Id::VariableCollection`, sweep-exempt by Task 6) → print count. Re-import churn behavior comes free from `upsert`.
+- **`cmd_watch`** (finish wiring per Task 12 sketch). Human mode prints one line per event (`changed → pulling…`, churn summary, `rate limited, waiting 30s`); `--json` prints one JSON object per line (`{"event": "pulled", …churn}`).
+
+- [ ] **Step 4: Run all tests, fix, pass** — `cargo test -p figmog` → PASS. Clippy clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/src/cli.rs examples/figmog/tests/cli.rs
+git commit -m "feat(figmog): design-system CLI commands (search/instances/components/styles/uses/vars/import-variables) and watch loop"
+```
+
+---
+
+### Task 14: docs, polish, full verification
+
+**Files:**
+- Create: `examples/figmog/README.md`
+- Modify: `README.md` (workspace) — add figmog to the examples list
+- Modify: `examples/figmog/src/*.rs` — rustdoc pass
+
+**Interfaces:** none new; documentation of everything above.
+
+- [ ] **Step 1: Write `examples/figmog/README.md`** covering, in order:
+ 1. What it is (2 sentences: the mirror + the zero-rate-limit reads) and the quick start:
+ ```console
+ $ export FIGMA_TOKEN=figd_… # figma.com → settings → security → personal access tokens
+ $ cargo run -p figmog -- pull "https://www.figma.com/design//"
+ $ cargo run -p figmog -- search "pricing card"
+ $ cargo run -p figmog -- watch # keep it fresh in another terminal
+ ```
+ 2. Command reference table (copy the spec §7 table, updated to what shipped).
+ 3. How sync works (3 sentences: Tier-3 polling, Tier-1 fetch only on change, upsert-diff means zero churn on no-ops) + plan-tier notes (free plan numbers).
+ 4. **Variables on a free plan** — the two paths, including the plugin-console export snippet, verbatim:
+ ```js
+ // Figma → Plugins → Development → Open console, then paste:
+ (async () => {
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
+ const variables = await figma.variables.getLocalVariablesAsync();
+ const out = { variables: {}, variableCollections: {} };
+ for (const c of collections)
+ out.variableCollections[c.id] = { id: c.id, name: c.name, modes: c.modes, defaultModeId: c.defaultModeId };
+ for (const v of variables)
+ out.variables[v.id] = { id: v.id, name: v.name, resolvedType: v.resolvedType,
+ variableCollectionId: v.variableCollectionId,
+ valuesByMode: v.valuesByMode, description: v.description, scopes: v.scopes };
+ console.log(JSON.stringify(out));
+ })();
+ // save the logged JSON, then: figmog import-variables vars.json
+ ```
+ 5. Manual live check (spec §9.6): the commands to run against a real file and what "fast" should look like.
+ 6. Limitations (variables modes caveat, no image renders, style defs derived from consumers, `FILE_UPDATE`-free polling design).
+- [ ] **Step 2: Add figmog to the workspace `README.md` examples list**, matching the existing bullet style:
+
+```markdown
+- `figmog` — a local mirror of a Figma file: sync once, then search, walk, and
+ query components/styles/variables with zero API calls. `cargo run -p figmog -- --help`
+```
+
+- [ ] **Step 3: Rustdoc pass** — every `pub` item in the crate has a doc comment (most were written in-task; sweep for gaps). `cargo doc -p figmog --no-deps` builds warning-free.
+
+- [ ] **Step 4: Full verification**
+
+```bash
+cargo fmt -p figmog --check
+cargo clippy -p figmog -- -D warnings
+cargo test -p figmog
+cargo test -p fold # untouched, but prove the workspace is still green
+cargo doc -p figmog --no-deps
+```
+All green, no exceptions. If `FIGMA_TOKEN` is available in the environment, also run the manual live check against the g3d file and record timings in the task notes (not in git).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples/figmog/README.md README.md examples/figmog/src
+git commit -m "docs(figmog): README, plugin export snippet, rustdoc pass"
+```
+
+---
+
+## Self-review checklist (run after writing, before execution)
+
+- Spec §4 data model → Tasks 2, 5, 10. Spec §5 pipeline (7 node sinks + 5 tables + meta) → Task 6. Spec §6 sync/watch → Tasks 6–9, 12. Spec §6a variables → Tasks 10, 11, 13, 14. Spec §7 CLI (13 subcommands) → Tasks 12, 13. Spec §8 practices → global constraints + per-task clippy. Spec §9 test plan items 1–6 → Tasks 4, 5 (flatten), 6, 7 (sync), 9 (watch), 10, 11, 13 (tokens), 12, 13 (CLI smoke), 14 (manual live).
+- Known intentional deviation from spec §9: the delta probe uses a `Map` **in front of** the `KeyedStream` pipeline root (counts deltas entering the graph), which is strictly stronger than the spec's "between stream and sinks".
+- `--from-file` on `pull` is an addition relative to spec §7 (offline ingestion; also what makes CLI tests hermetic). Document it in the README table.
diff --git a/docs/superpowers/plans/2026-08-16-figmog-bench.md b/docs/superpowers/plans/2026-08-16-figmog-bench.md
new file mode 100644
index 0000000..4a7f086
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-figmog-bench.md
@@ -0,0 +1,42 @@
+# figmog bench Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox syntax.
+
+**Goal:** `figmog bench` — the self-contained load-test demo of spec §13: synthetic corpus → timed cold sync → timed zero-churn re-pull → MCP serve load over real stdio → percentile report.
+
+**Architecture:** Spec §13 of `docs/superpowers/specs/2026-08-15-figmog-build-design.md` is the binding authority — read it first. One new module `src/bench.rs` (corpus generator + phase runners + report types), one CLI subcommand wiring, README demo section.
+
+**Tech Stack:** No new dependencies. Seeded LCG for determinism; `std::process` + `current_exe()` for the serve phase; `Instant` timing; sort-based percentiles.
+
+**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md §13
+
+## Global Constraints
+
+- Zero new crates. Determinism: same `--nodes` → byte-identical corpus JSON (unit-tested). No wall-clock/randomness in the generator (LCG seed is a constant).
+- Stdout purity: human table OR `--json` object, never both; diagnostics to stderr. Exit nonzero on any phase failure or any `isError` tool result.
+- Temp dir cleaned unless `--keep` (which prints the path). Bench never touches the network.
+- Existing 112 tests stay green unchanged. Gates: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check`.
+- Commits end with the `Co-Authored-By: Claude Fable 5 ` trailer.
+
+---
+
+### Task 1: the whole bench (module + CLI + tests + README)
+
+**Files:**
+- Create: `examples/figmog/src/bench.rs`; Modify: `src/lib.rs` (`pub mod bench;`), `src/cli.rs` (Bench subcommand + dispatch), `examples/figmog/README.md` (Demo section), workspace `README.md` (bullet mentions the bench)
+- Test: unit tests in `bench.rs` + one e2e smoke in `tests/cli.rs`
+
+**Interfaces:**
+- `bench::generate_corpus(nodes: usize) -> serde_json::Value` — a full GET-file-shaped response. Structure: ~1 CANVAS page per 250 nodes; per page: auto-layout FRAMEs each containing TEXT children (characters = 4-8 words from a fixed 64-word const list, LCG-picked); one COMPONENT_SET ("Button", 2 variant COMPONENT children with `componentPropertyDefinitions`) on the first page; every ~20th frame child is an INSTANCE with `componentId` pointing at a variant and `componentProperties`; two styles (S:1 FILL, S:2 TEXT) referenced by ~each frame/text via `styles`; every ~10th frame carries a `boundVariables` fill binding to one of 3 variable ids; every node has `absoluteBoundingBox` laid out on a grid. Node ids `"p:i"` scheme, name pool from the word list. Exact node count == `nodes` (count nodes as you emit; stop precisely — unit-tested).
+- `bench::run(opts: BenchOpts) -> Result` where `BenchOpts { nodes, calls, keep, exe: PathBuf }` and `BenchReport` (Serialize) carries: corpus {nodes, bytes, gen_ms}, cold {flatten_ms, sync_ms, records, records_per_s}, repull {ms, churn_zero: bool}, load {per_tool: Vec, total_calls, wall_s, req_per_s}. Phases per spec §13: cold sync + re-pull via the LIBRARY (flatten_file + open_store! + store::sync at a concrete site in bench.rs — same pattern as cli.rs); serve load by spawning `opts.exe` with `["serve","--no-upstream","--no-watch","--db",…]`, doing initialize + notifications/initialized, then M rotating tools/call frames (mix per spec: search w/ rotating words, node w/ LCG-picked real ids, where /layoutMode==VERTICAL, stats, tree depth 2, instances "Button"), timing write→response-line with Instant. Kill child via guard on drop; close stdin at end for clean exit.
+- CLI: `Bench { #[arg(long, default_value="10000")] nodes: usize, #[arg(long, default_value="5000")] calls: usize, #[arg(long)] keep: bool }` — note bench does NOT need a resolved db/mirror: handle it BEFORE resolve_db in dispatch (like nothing else needs the Db) — `exe` = `std::env::current_exe()`. `--json` global flag prints `serde_json::to_string_pretty(&report)`; human mode prints the phase lines + per-tool table + headline: `figmog served {total} queries in {wall:.1}s ({req_per_s:.0} req/s). Figma's Tier-1 API budget on a free plan: ~10 file requests per MINUTE.`
+- Percentiles: sort the per-tool Vec; pN = v[((n-1) * N / 100)] (document the convention).
+
+- [ ] **Step 1 (TDD, generator):** unit tests first: exact node count for 100/1000/10007 (odd number); byte-identical JSON across two calls; contains ≥1 COMPONENT_SET, ≥1 INSTANCE, TEXT nodes with non-empty characters; flatten_file succeeds on it and yields exactly `nodes` node records. Run RED, implement generator, GREEN.
+- [ ] **Step 2 (phases):** implement cold-sync/re-pull phases (assert churn zero in-code, else Err) and the serve-load phase + report assembly. Unit-test percentile math on a known vector.
+- [ ] **Step 3 (wiring + e2e):** CLI subcommand + dispatch (before resolve_db); e2e smoke in tests/cli.rs: `figmog bench --nodes 300 --calls 60 --json` → exit 0, stdout parses as JSON, `repull.churn_zero == true`, `load.total_calls == 60`, every per-tool p50 ≥ 0. Keep it <30s in CI (300 nodes is plenty).
+- [ ] **Step 4 (docs):** README "Demo: load-testing the server" section — the one command (`cargo run --release -p figmog -- bench`), what it does (4 phases), sample output block (from a real run on this machine, dev profile is fine — note profile), the headline framing. Workspace README figmog bullet gains "with a built-in load-test demo (`figmog bench`)".
+- [ ] **Step 5:** full gates, commit `feat(figmog): figmog bench — self-contained load-test demo` + trailer.
+
+## Self-review checklist
+- Spec §13 coverage: corpus determinism → Step 1; phases/report/headline → Steps 2-4; constraints (no deps, stdout purity, cleanup, nonzero exit) → all steps; non-goals respected (sequential single pipe; no proxy benchmarking).
diff --git a/docs/superpowers/plans/2026-08-16-figmog-migration.md b/docs/superpowers/plans/2026-08-16-figmog-migration.md
new file mode 100644
index 0000000..61cb6ed
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-figmog-migration.md
@@ -0,0 +1,59 @@
+# figmog standalone migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Execute docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md — figmog at the root of the (already-created, empty) `sanctuarycomputer/figmog` repo, fold via pinned git dep, cuts + shakeout done, CI + release workflow in place, tagged v0.0.1 published pre-release.
+
+**Precondition:** the multi-file serve milestone is complete and pushed on `worktree-figmog` (its final state is what migrates). Do not start while any implementer is active on this worktree.
+
+**Mechanics note (sandbox):** this session's git operations are confined to this worktree. The migration therefore happens on an **orphan branch** here (`figmog-standalone`), whose tree IS the new repo's root layout. **PR flow (user-directed):** first seed the empty repo's `main` with a minimal init commit (LICENSE + one-line README), then push the orphan branch as `first-pass` and open a PR against `main`. All five tasks commit onto that branch (pushed after each task); per-pillar adversarial reviews run against the PR; when clean, the PR merges into `main` and the release tag is cut from `main`. After the final task, switch this worktree back to `worktree-figmog`.
+
+**Version + release (user-directed):** crate version **0.0.1**, tag **v0.0.1**, release **published as a pre-release** (not draft) so the user can download and test the binary. The fold-license caveat remains a README sentence about broader distribution; the user has accepted publishing on their own repo for testing.
+
+**Spec:** docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md (binding; read fully before any task)
+
+## Global Constraints
+- The spec's §6 standing rules apply to all new-repo content (determinism, frozen sinks, append-only enums, no fold patches, gates, no new deps).
+- Every task ends green IN THE NEW LAYOUT: `cargo test`, `cargo clippy --no-deps -- -D warnings`, `cargo fmt --check` run at the orphan branch root.
+- Commit trailer everywhere: `Co-Authored-By: Claude Fable 5 `.
+- The clog-fork branch `worktree-figmog` is never modified by this plan.
+
+---
+
+### Task 1: import + git-dep switch (the repo exists after this)
+
+- [ ] **Step 1 — pin determination: DONE by the controller (2026-08-16).** Upstream fetched (`bogkit-upstream` remote exists in this worktree); `fold` at `bogkit-upstream/main` is source-identical to our tree (only a `readme = "readme.md"` metadata line + readme files differ; `anny` likewise readme-only). **The pin is `rev = "20f2ca50d5d06f51edfe8b8570c0fb48caf9eb81"`.** Task 1 Step 3's full-suite gate remains the executable proof. (Upstream still has no LICENSE anywhere — the §3 release gate stands.)
+- [ ] **Step 2 — orphan branch + layout:** `git switch --orphan figmog-standalone`; populate from `worktree-figmog`'s tree (use `git restore --source worktree-figmog -- examples/figmog docs` then move): crate files from `examples/figmog/*` to root (src/, tests/, README.md → kept for now, Cargo.toml rewritten standalone: `[package] name figmog version 0.0.1 edition 2024 license MIT` + the git dep `fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }`, same crates.io deps/dev-deps, NO workspace section); `docs/history/` gets the old spec + all figmog plan docs verbatim; LICENSE = MIT (year 2026, copyright sanctuary computer); minimal `.gitignore` (target/, .figmog/). Nothing else yet (CI, SPEC.md, README rewrite are later tasks).
+- [ ] **Step 3 — build against the git dep:** `cargo test` at root (network fetch of bogkit occurs here). All 15x tests must pass unchanged — this proves the pin is faithful. Then clippy/fmt gates.
+- [ ] **Step 4 — seed main, push branch, open PR:** first seed the empty repo: create a tiny init commit (LICENSE + one-line README stub) on a temp orphan branch and `git push https://github.com/sanctuarycomputer/figmog.git :main`. Then commit the import on `figmog-standalone` (`import figmog from sanctuarycomputer/clog (branch worktree-figmog, PR #1) as standalone crate` + trailer), `git push https://github.com/sanctuarycomputer/figmog.git figmog-standalone:first-pass`, and `gh pr create -R sanctuarycomputer/figmog --base main --head first-pass` (title "figmog first pass", body summarizing the import + planned task commits; PR body ends with the standard generated-with footer).
+
+### Task 2: the cuts
+
+- [ ] Remove `src/bench.rs`, `src/repl.rs` (+ their lib.rs mods, Cmd::Bench variant + dispatch, bench/repl tests in tests/cli.rs + tests/serve.rs if any, corpus references). Remove `Cmd::Watch` + `cmd_watch` (keep helpers serve/sessions still use — compiler tells). Remove human-mode output: every read command prints `serde_json::to_string_pretty` only; delete the printer/table helpers; remove the `--json` flag (JSON is the only mode) — errors become JSON on stderr unconditionally; update every test that passed `--json` (mechanical flag removal) and any asserting human output (delete those assertions, keep the JSON ones). Spec §4 lists this as approved — the test edits here are authorized wholesale but must be enumerated in the report.
+- [ ] Post-cut dead-code sweep: build with `-D warnings` (dead_code surfaces), remove orphans; manual pass over remaining `pub(crate)` items for zero-caller leftovers.
+- [ ] Gates; commit `remove bench/REPL/watch and human output mode (JSON-only CLI)`; push.
+
+### Task 3: debt payment (spec §4's list, verbatim scope)
+
+- [ ] Fix each: variable_edges dedup (BTreeSet or remove + honest comment); merge_registry dedup by actual local names; hoist namespace check above per-proxied-call rtx; cache::store surfaces serialize errors (Result); wrong-type args errors say expected/got; `--interval` overflow clamp; serve stdout writes tolerate closed pipe (write! + map_err → clean exit); visibility mismatches (pub mod serve etc.); obj_map borrow instead of clone; README gains Tier-3 budget line (interim — full README rewrite is T4). Add focused tests where a fix changes observable behavior (wrong-type message, interval clamp).
+- [ ] Gates; commit `pay down deferred review debt`; push.
+
+### Task 4: structure + docs
+
+- [ ] Split cli.rs (~1.4k lines): `src/cli/mod.rs` (clap types + dispatch + run), `src/cli/pull.rs` (pull/do_pull/PullError/open_store_checked/current helpers), `src/cli/read.rs` (read command fns), `src/cli/call.rs` (tools/call/import-variables). Pure moves; suite green unchanged.
+- [ ] `docs/SPEC.md`: consolidated CURRENT-state spec (architecture, data model, pipeline, sync, serve/MCP tools incl. multi-file, proxy + cache, variables story, bench REMOVED — no version archaeology; ~the §2-§14 content that still exists, rewritten present-tense). Old spec stays in docs/history/ untouched.
+- [ ] README.md rewrite per spec §2 (standalone: what it is, install from GitHub Releases incl. macOS quarantine note + the fold-license gate sentence while unresolved, quick start, MCP setup incl. multi-file/URL-addressed usage, CLI reference, tool tables, limitations). New-repo CLAUDE.md per spec §6.
+- [ ] Gates + `cargo doc --no-deps` warning-free; commit `standalone docs and module structure`; push.
+
+### Task 5: CI + release + tag
+
+- [ ] `.github/workflows/ci.yml`: on push/PR to main — ubuntu + macos runners: `cargo test`, `cargo clippy --no-deps -- -D warnings`, `cargo fmt --check`.
+- [ ] `.github/workflows/release.yml` per spec §5: on tag `v*` — matrix {aarch64-apple-darwin on macos-14, x86_64-apple-darwin on macos-13, x86_64-unknown-linux-gnu on ubuntu-latest}; `cargo build --release`; strip; `tar czf figmog-${TAG}-${TARGET}.tar.gz -C target//release figmog`; sha256 into SHA256SUMS; `gh release create "$TAG" --prerelease --title "$TAG"` + upload artifacts (use `softprops/action-gh-release` OR plain `gh` CLI — prefer plain `gh`, zero third-party actions beyond actions/checkout + dtolnay/rust-toolchain or rustup manual; document the choice).
+- [ ] Push; verify CI runs green on the actual repo (`gh run watch/list -R sanctuarycomputer/figmog`); fix-forward if runner reality differs (allowed: iterative commits, each pushed, until green — list them).
+- [ ] Tag `v0.0.1`, push tag, confirm the release appears with 3 artifacts + checksums (`gh release view v0.0.1 -R sanctuarycomputer/figmog`). Publish as PRE-RELEASE (user-directed for testing); README keeps the fold-license sentence for broader distribution.
+- [ ] Final: switch this worktree back to `worktree-figmog`. Commit nothing further there.
+
+## Self-review checklist
+- Spec §2 layout → T1/T4; §3 dep+pin+gate → T1 (+README sentence T4); §4 cuts+debt → T2/T3; §5 release → T5 (pre-release publish per user direction supersedes the spec's draft-only rule — ledger this as a ruling); §6 CLAUDE.md → T4; §7 sequencing → precondition + task order; §8 non-goals respected (no tap, no signing, no filter-repo).
+- Per-pillar adversarial reviews (user-directed) map onto the task reviews: T1 = engine/import fidelity, T2+T3 = CLI surface & hardening, T4 = structure/docs, T5 = supply chain/CI — plus one whole-PR final review before merge.
+- Risk center: T1's pin-parity check (fold drift would silently change engine behavior — the full-suite gate at T1 Step 3 is the proof).
diff --git a/docs/superpowers/plans/2026-08-16-figmog-multifile.md b/docs/superpowers/plans/2026-08-16-figmog-multifile.md
new file mode 100644
index 0000000..69c8006
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-figmog-multifile.md
@@ -0,0 +1,50 @@
+# figmog multi-file serve Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Spec §14 — `figmog serve` becomes a multi-file server: every local tool takes an optional `file` (URL/key), files auto-mirror on first reference, `figmog_open`/`figmog_files` tools, round-robin watch.
+
+**Architecture:** Spec §14 of `docs/superpowers/specs/2026-08-15-figmog-build-design.md` is the binding authority — read it in full. Core move: extract each mirrored file into a `FileSession` whose unnameable store is captured in boxed closures (the crate's established pattern); serve routes by resolving the `file` arg to a session.
+
+**Tech Stack:** No new deps.
+
+**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md §14
+
+## Global Constraints
+- Zero new crates. All 145 existing tests green unchanged EXCEPT: the serve e2e's tools/list count assertions move 17→19 and any initialize-instructions assertion must keep passing (the sentence is appended, existing substring stays) — those specific edits are authorized; list every test edit in the report.
+- Single-file behavior preserved: `figmog serve ` behaves exactly as today (default file = that file; no `file` arg needed on any tool).
+- Stdout purity, determinism (session listing sorted by open order; `figmog_files` output deterministic), clean EOF exit unchanged.
+- Gates: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check`.
+- Commit trailer: `Co-Authored-By: Claude Fable 5 `.
+
+---
+
+### Task 1: FileSession manager + routing + new tools + multiplexed watch
+
+**Files:**
+- Create: `examples/figmog/src/sessions.rs`; Modify: `src/serve.rs`, `src/cli.rs` (Serve takes `files: Vec` positional + hidden `--figmog-root ` flag defaulting ".figmog" used for session store paths — testability per spec §14), `src/mcp.rs` (only the INSTRUCTIONS const gains the spec's exact appended sentence + its pinning test updated), `src/dispatch.rs` if needed.
+- Test: unit tests in sessions.rs; serve.rs registry test updated 17→19.
+
+**Interfaces:**
+- `sessions::FileSession { key: String, name: String, dispatch: Box Result>, pull: Box Result>, watermark: Box Option>, watcher: Watcher, backoff: Duration }` — built by `sessions::open_session(root: &Path, key: &str, api_token: Option<&str>, pull_now: bool) -> Result`; the closure factory owns the `open_store!` concrete site (move the existing per-tool rtx dispatch + inline pull + eviction blocks INTO the factory, generalized from serve.rs's current single-store code — this is a refactor-move, behavior preserved).
+- `sessions::SessionManager { sessions: Vec, root, token }` with `resolve(&mut self, file_arg: Option<&str>) -> Result<&mut FileSession, String>` implementing spec §14's resolution rules (explicit → find-or-auto-open; omitted → default rules incl. the isError text naming figmog_open/figmog_files); `open(&mut self, file_ref) -> Result<&mut FileSession, String>` (parse_file_ref, dedupe by key); `list(&self) -> Value` for figmog_files.
+- serve.rs: registry gains `figmog_open {file required}` + `figmog_files {}` (19 local tools; input schemas per pattern); every existing local ToolDef's schema gains optional `file` string property (description: "Figma file URL or key; omit for the default mirrored file"); the FnHandler closure extracts `file` from args (removing it before tool-specific arg parsing) and routes through SessionManager::resolve. figmog_sync syncs the RESOLVED session. Watch tick: round-robin — keep one `next_session_idx`; per tick poll ONE session's watcher (deadline = max(interval / session_count, 2s) per spec); Changed → that session's pull(); backoff discipline per session. Zero sessions + watch → just idle ticks (no polling).
+- CLI Serve: `files: Vec` positional (zero or more); startup opens each (pull if store empty); first = default. `--figmog-root` threaded to SessionManager (and to resolve_db? NO — CLI single-file semantics unchanged; the flag only affects serve's sessions).
+
+- [ ] **Step 1 (TDD):** sessions unit tests with a scripted closure factory (no real stores): resolve explicit/default/none-mirrored error text; dedupe; auto-open counts. RED → implement sessions.rs → GREEN.
+- [ ] **Step 2:** refactor serve.rs onto SessionManager (single-file path first — all existing tests must pass unchanged here, except none should need edits at this step), then add file-arg routing + the two new tools + schema additions + instructions sentence (now the 17→19 and instructions test edits land, listed in the report).
+- [ ] **Step 3:** multiplexed watch tick + gates + commit `feat(figmog): multi-file serve — URL-addressed tools, figmog_open/figmog_files`.
+
+---
+
+### Task 2: multi-file e2e + docs
+
+**Files:**
+- Modify: `examples/figmog/tests/serve.rs` (new e2e), `examples/figmog/tests/common/mod.rs` (a second small fixture `fixture_other()` — distinct name "OtherFixture", a few nodes, distinct text), `examples/figmog/README.md`, workspace README bullet.
+
+- [ ] **Step 1 (TDD):** e2e per spec §14 testing: pre-build two stores under a temp `--figmog-root` (via `pull --from-file --db //db` with two different fixtures — note the CLI's --db is explicit so root layout is constructed by the test), start `serve --no-upstream --no-watch --figmog-root ` with BOTH keys as positional args; assert: tools/list has 19 tools and every local tool schema contains the optional `file` property; `figmog_files` lists both (first = default); `figmog_search {query:, file:}` hits; same query without `file` misses (proves default routing); `figmog_status {file:}` returns the other file's name; `figmog_open {file:"garbagekey1234567890"}` → isError (no token); omitted-file error case NOT triggerable here (a default exists) — cover via a second serve spawn with NO positional files: a tool without `file` → isError naming figmog_open.
+- [ ] **Step 2:** README: "Multiple files" section (URL-per-call usage, figmog_open/figmog_files, zero-file startup for `claude mcp add`, proxied-tools caveat verbatim from spec §14); workspace bullet unchanged or +"multi-file". Full gates; commit `feat(figmog): multi-file serve e2e and docs`.
+
+## Self-review checklist
+- Spec §14 coverage: surface (file arg/open/files/zero-file startup) → T1+T2 e2e; mechanics (sessions/watch/cache) → T1; caveat + docs → T2; non-goals respected (no cross-file queries, no idle eviction, CLI unchanged).
+- Single-file regression safety: T1 Step 2's mid-step gate (existing tests green before feature lands).
diff --git a/docs/superpowers/plans/2026-08-16-figmog-repl.md b/docs/superpowers/plans/2026-08-16-figmog-repl.md
new file mode 100644
index 0000000..0799be5
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-figmog-repl.md
@@ -0,0 +1,42 @@
+# figmog bench --interactive (REPL) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** The interactive REPL mode of spec §13 "Interactive mode" — requests visible as they fire, live API side-by-side, session percentiles.
+
+**Architecture:** Spec §13 "Interactive mode (`--interactive`)" is the binding authority — read it in full. One new module `src/repl.rs` driven from `bench::run`'s setup (reuse the corpus/real-file setup, serve child spawn, frame pump, and derived query pool exactly as built — refactor shared pieces out of `bench.rs` rather than duplicating; the one-shot path must keep behaving identically).
+
+**Tech Stack:** No new deps. `std::io::IsTerminal` for TTY detection; raw ANSI escapes; plain `stdin().lines()` (no readline).
+
+**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md §13
+
+## Global Constraints
+- Zero new crates. One-shot bench behavior and ALL existing tests unchanged. `--interactive` + `--json` = usage error (exit 1, message on stderr).
+- Non-TTY stdout → no ANSI codes (the e2e depends on this). EOF or `quit` → child reaped, clean exit 0.
+- Gates: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check`.
+- Commit trailer: `Co-Authored-By: Claude Fable 5 `.
+
+---
+
+### Task 1: the REPL (module + wiring + tests + README)
+
+**Files:**
+- Create: `examples/figmog/src/repl.rs`; Modify: `src/bench.rs` (extract/reuse setup + frame-pump + query-pool helpers; make the pieces `pub(crate)`), `src/lib.rs`, `src/cli.rs` (`--interactive` flag on Bench + the json-conflict check), `examples/figmog/README.md` (Demo section gains the REPL walkthrough), workspace README bullet unchanged.
+- Test: unit tests for the command parser (line → parsed command, incl. bad input errors) and the latency-line formatter (plain mode); one scripted e2e in `tests/cli.rs` piping `help\nstats\nsearch garden\nrun 20\nreport\nquit\n` into `figmog bench --nodes 300 --interactive` (non-TTY → plain output): assert exit 0, output contains the per-request lines (e.g. `figmog_search`), a `run` burst of 20 numbered lines, a report table, no ANSI escape bytes (`\x1b` absent), and the serve child exits (no zombie — process table not asserted, rely on guard + clean exit).
+
+**Interfaces:**
+- `repl::run(session: &mut BenchSession, real_file: Option) -> Result<(), String>` where `BenchSession` is the extracted struct owning the serve child + pump + derived query pool + cumulative `Vec<(tool, Duration)>` stats; `RealFileCtx { key, api: UreqApi }` enables the `api …` commands.
+- Command enum + `parse_line(&str) -> Result` (unit-tested): Help, Quit, Run(usize), Report, Api(ApiCmd), Call{tool, args}, Tool{name, args} for the shorthands per spec's list (each shorthand builds the tool's JSON args; `where [value]` value parsed as JSON with bare-word→string fallback like the CLI's --equals; `node children` sets children:true).
+- Latency line: `#{seq:>4} {tool:<18} {args_digest:<32} {ms:>8.2}ms {digest}` — args digest truncated at 32 chars; result digest = hits count for array results, `name` for node results, `isError` text (red) for errors. Color thresholds per spec (10ms green / 100ms yellow / else red) via a `fn paint(s, color, tty)` helper.
+- `run N`: reuse the one-shot mixed-workload rotation, print each line as fired, then a burst percentile table (reuse the existing ToolStats/percentile code).
+- `report`: same table over the session's cumulative stats.
+- `api node ` / `api meta`: call the existing UreqApi helpers, print with an `API` tag + "spent 1 Tier-1/Tier-3 call" note; 429 prints Retry-After in red and continues.
+
+- [ ] **Step 1 (TDD):** parser + formatter unit tests RED → implement → GREEN.
+- [ ] **Step 2:** extract `BenchSession` from bench.rs (one-shot path re-verified: full `cargo test -p figmog` green before proceeding).
+- [ ] **Step 3:** repl loop + CLI wiring + json-conflict error; manual smoke (pipe commands, eyeball output); scripted e2e added and green.
+- [ ] **Step 4:** README walkthrough (commands table + a short sample transcript from a real run, incl. the `api node` side-by-side framing for real-file mode).
+- [ ] **Step 5:** full gates; commit `feat(figmog): interactive bench REPL — watch requests fire live`.
+
+## Self-review checklist
+- Spec §13 interactive coverage: every listed shorthand parses; run/report/api/call/help/quit; color TTY-gating; json-conflict; EOF clean exit. Non-goals respected (no readline). One-shot path byte-identical behavior (existing tests prove).
diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md
new file mode 100644
index 0000000..281d233
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md
@@ -0,0 +1,956 @@
+# figmog — a fold-backed local mirror of a Figma file
+
+**Date:** 2026-08-15
+**Status:** Draft for review
+**Crate:** `examples/figmog`
+**Target file (dev/manual testing):** `flAtUnMfzvA5daBSTFQK35` (g3d-Index-Web-Handoff)
+
+## 1. Problem
+
+Figma's MCP server and REST API are slow and aggressively rate limited.
+Since the November 2025 rate-limit overhaul, the file endpoints
+(`GET /v1/files/:key`, `.../nodes`, `/v1/images`) are Tier 1: **~10
+requests/min on the free (Starter) plan**. There is no delta API — the only
+way to see what changed is to re-download the entire multi-MB document tree.
+Webhooks are unavailable on the free plan, and `FILE_UPDATE` is debounced up
+to 30 minutes anyway.
+
+The result: every agent interaction with a Figma file re-pays a huge fetch
+through a stingy per-minute bucket.
+
+## 2. Goal
+
+**Lightning-fast local reads of one Figma file for agents.** A sync engine
+pulls the file when it changes (spending Tier-1 budget only on actual
+changes) and maintains materialized indexes in a fold database. A CLI reads
+those indexes locally — point lookups, tree walks, full-text search,
+design-system queries — with zero Figma calls and zero rate limits.
+
+Fold's `KeyedStream` upsert semantics synthesize the delta API Figma lacks:
+re-upserting a byte-identical record causes **zero graph churn**; a changed
+record is retracted from and re-inserted into every index atomically. A
+10,000-node file where one layer was renamed costs one fetch plus one
+retract/insert pair.
+
+First-class support for Figma's design-system features — components,
+component sets, variants and their property axes, styles, bound-variable
+references — because a later layer will sync this design system into
+Tailwind. That layer is **out of scope** here; this spec only guarantees the
+data it needs is mirrored and queryable.
+
+### Non-goals (v1)
+
+- Image renders / thumbnails.
+- MCP server in v1 (see §11 for the v2 design: a `serve` subcommand with
+ integrated sync — same store, no schema migration).
+- Multi-file / team mirroring (the store layout is per-file-key, so this is
+ additive later).
+- Embeddings / HNSW semantic search (BM25 over names + text is enough for
+ v1; an ese branch can be added to the pipeline later; keeping ese out
+ keeps builds fast).
+- Writing back to Figma.
+- Dev Mode extras (annotations, measurements, dev resources, Code
+ Connect) — not needed by the Tailwind layer.
+- Calling the Enterprise-only Variables REST endpoints. Variables are
+ still fully supported — see §6a for how, on a free plan.
+
+## 3. Architecture
+
+Three units with one shared type vocabulary:
+
+```
+ ┌─────────────┐ file JSON ┌──────────────┐ (Id, Rec) recs ┌───────────────┐
+Figma ───▶│ api (HTTP) │──────────────▶│ flatten │─────────────────▶│ store (fold) │
+ └─────────────┘ └──────────────┘ └───────┬───────┘
+ ▲ ▲ │ rtx (local)
+ pull │ │ watch (poll Tier-3 metadata; Tier-1 fetch only on change) ▼
+ ┌──┴────┴─────────────────────────────────────────────────────────────────────┐
+ │ cli │
+ │ pull · watch · tree · get · search · instances · components · styles · … │
+ └─────────────────────────────────────────────────────────────────────────────┘
+```
+
+- **`api`** — thin HTTP client behind a `FigmaApi` trait (so every other
+ unit is testable without a network). Two calls: `file_meta(key)` (Tier 3,
+ cheap, for change detection) and `file(key)` (Tier 1, the full document).
+ Honors `Retry-After` on 429.
+- **`flatten`** — pure function: file JSON → deterministic
+ `Vec<(Id, Rec)>`. All parsing, variant-name parsing, and canonicalization
+ lives here. No I/O.
+- **`store`** — owns the fold `KeyedStream` and pipeline definition, the
+ sync transaction (upsert + remove-vanished + meta bump, all in one `wtx`),
+ and typed read helpers.
+- **`cli`** — clap-based command surface; every read command supports
+ `--json` for agents and a human-readable default.
+
+### Module map
+
+```
+examples/figmog/src/
+ main.rs — arg parsing, dispatch, process exit codes
+ api.rs — FigmaApi trait, UreqApi impl, RateLimited/Http errors
+ flatten.rs — file JSON -> Vec<(Id, Rec)>; pure, deterministic
+ model.rs — Id, Rec, NodeRec, ComponentRec, ComponentSetRec, StyleRec,
+ VariableRec, VariableCollectionRec, FileMeta
+ store.rs — pipeline construction, open(), sync(), read helpers
+ cli.rs — subcommand impls, output formatting (human + JSON)
+tests/
+ flatten.rs — unit tests over fixtures
+ sync.rs — churn/diff/removal tests over fixtures
+ cli.rs — CLI smoke tests against a fixture-built DB
+ fixtures/ — small SYNTHETIC file JSONs (see §9 on provenance)
+```
+
+## 4. Data model
+
+One `KeyedStream` over one fjall store at `.figmog//`.
+Everything the sync writes — nodes, design-system metadata, and the file
+meta row — flows through the same stream, so a sync commits **atomically**:
+readers never observe a half-applied pull or a version number ahead of its
+data.
+
+```rust
+enum Id { Node(String), Component(String), ComponentSet(String), Style(String),
+ Variable(String), VariableCollection(String), Meta }
+enum Rec { Node(NodeRec), Component(ComponentRec), ComponentSet(ComponentSetRec),
+ Style(StyleRec), Variable(VariableRec),
+ VariableCollection(VariableCollectionRec), Meta(FileMeta) }
+```
+
+`NodeRec` (one per node in the document tree, keyed by Figma's stable node
+id):
+
+| field | type | notes |
+|---|---|---|
+| `id` | `String` | Figma node id, e.g. `"12:34"` |
+| `parent_id` | `Option` | `None` only for the document root |
+| `child_index` | `u32` | position within parent |
+| `page_id` | `String` | enclosing CANVAS id (root/pages: own id) |
+| `node_type` | `String` | `FRAME`, `TEXT`, `COMPONENT`, `COMPONENT_SET`, `INSTANCE`, … |
+| `name` | `String` | layer name |
+| `visible` | `bool` | absent in JSON ⇒ `true` |
+| `text` | `Option` | `characters` for TEXT nodes |
+| `component_id` | `Option` | INSTANCE → its component's node id |
+| `component_properties` | `Vec<(String, String)>` | INSTANCE `componentProperties` assignments (variant values, booleans, text, instance swaps), **sorted by key**; values stringified |
+| `property_definitions` | `Option` | `componentPropertyDefinitions` as canonical JSON — present on **both** COMPONENT and COMPONENT_SET nodes (property types: VARIANT, BOOLEAN, TEXT, INSTANCE_SWAP) |
+| `style_refs` | `Vec<(String, String)>` | node `styles` map as (style_type, style_id), **sorted** — style types FILL, TEXT, EFFECT, GRID |
+| `bound_variables` | `Vec<(String, String)>` | every variable binding in this node's JSON as (json_path, variable_id), **sorted** — collected by a generic recursive scan for `boundVariables` objects (robust to Figma adding bindable properties), not per-property typed extraction |
+| `abs_bounds` | `Option<[f64; 4]>` | absoluteBoundingBox x, y, w, h |
+| `raw` | `String` | canonical JSON of the node **with `children` stripped** — full fidelity for `get` |
+
+`ComponentRec` / `ComponentSetRec` (from the file response's `components` /
+`componentSets` maps, keyed by node id): `node_id`, `key` (global key),
+`name`, `description`, `remote` (library component used by an instance vs
+defined locally), and for components `component_set_id: Option`.
+`StyleRec` (from `styles`, keyed by style id): `style_id`, `key`, `name`,
+`style_type`, `description`, `remote`. `FileMeta`: `name`, `version`,
+`last_modified`, `synced_at_unix_ms` — `last_modified` is the *file*
+endpoint's (`GET /v1/files/:key`) `lastModified` field, populated on every
+`pull`; see §6 for how this compares against the *meta* endpoint's
+`last_touched_at` during `watch`.
+
+`VariableRec` / `VariableCollectionRec` (populated by `import-variables`
+only — see §6a): variable `id`, `name`, `resolved_type`
+(COLOR/FLOAT/STRING/BOOLEAN), `collection_id`, `values_by_mode` as
+canonical JSON (values or `VARIABLE_ALIAS` refs), `description`,
+`scopes`; collection `id`, `name`, `modes` as sorted (mode_id, mode_name)
+pairs, `default_mode_id`.
+
+Variant support falls out of this model: a COMPONENT_SET node's variants are
+its COMPONENT children (the `children` index gives the axis), the set's
+`property_definitions` carries the axes/options, and each INSTANCE's
+`component_properties` carries its chosen variant values. `instances_of`
+answers "every usage of this component"; joining through
+`ComponentRec.component_set_id` answers "every usage of any variant of this
+set".
+
+### Determinism (byte-equality contract)
+
+`KeyedStream::upsert` detects change by comparing postcard bytes, so
+`flatten` must be a pure, deterministic function of the file JSON:
+
+- Every map-shaped field is stored as a **sorted `Vec` of pairs**, never a
+ `HashMap`.
+- `raw` / `property_definitions` are re-serialized through `serde_json`
+ **without** the `preserve_order` feature, so object keys are
+ BTreeMap-sorted and canonical.
+- No timestamps, randomness, or environment reads inside `flatten`.
+ (`FileMeta.synced_at_unix_ms` is the one wall-clock value and lives only
+ in the meta row, which is expected to change every sync.)
+
+## 5. Pipeline
+
+```rust
+KeyedStream::new(db_path, (
+ // -- node branch: FilterMap Keyed -> Keyed --
+ FilterMap(node_only, (
+ terminal::Table::new("nodes"), // id -> NodeRec
+ Map(|n| Keyed::new(n.parent_id?, (n.child_index, n.id)),
+ terminal::Multimap::new("children")), // parent -> (idx, child)
+ FilterMap(|n| non_empty(name + text),
+ terminal::search::Bm25::new("text")), // full-text
+ FilterMap(|n| n.component_id.map(|c| Keyed::new(n.id, c)),
+ terminal::InvertedIndex::new("instances_of")), // component id -> instances
+ FlatMap(|n| n.style_refs -> Keyed::new(n.id, style_id),
+ terminal::InvertedIndex::new("styled_by")), // style id -> nodes
+ FlatMap(|n| n.bound_variables -> Keyed::new(n.id, variable_id),
+ terminal::InvertedIndex::new("bound_to")), // variable id -> nodes
+ Map(|n| Keyed::new(n.id, n.node_type),
+ terminal::InvertedIndex::new("by_type")), // type -> nodes
+ )),
+ // -- design-system branch --
+ FilterMap(component_only, terminal::Table::new("components")),
+ FilterMap(component_set_only, terminal::Table::new("component_sets")),
+ FilterMap(style_only, terminal::Table::new("styles")),
+ FilterMap(variable_only, terminal::Table::new("variables")),
+ FilterMap(collection_only, terminal::Table::new("variable_collections")),
+ // -- meta branch --
+ FilterMap(meta_only, terminal::Table::new("meta")),
+))
+```
+
+(Pseudocode: real code uses fold's `FilterMap::new(closure, next)` forms.
+The point is the shape: one stream, typed branches, seven node sinks.)
+
+Notes:
+
+- `children` values sort by postcard encoding, which is **not** numeric
+ order for varint-encoded `u32` — readers sort the returned `Vec` by
+ `child_index` before output. Alternatively `child_index` is stored
+ big-endian-encoded; decision left to implementation, but output order
+ must be numeric.
+- All closures are pure functions of the record, as fold requires for
+ retraction to cancel.
+- Sink names are frozen here; they are part of the on-disk schema. Any
+ rename is a breaking change requiring a re-pull (acceptable v1 policy:
+ `figmog pull --fresh` wipes and rebuilds).
+
+## 6. Sync engine
+
+**`pull`** — one full refresh, one `wtx`:
+
+1. `api.file(key)` — single Tier-1 request. No `geometry=paths` (vector
+ outlines bloat the payload and serve no v1 query).
+2. `flatten` → `Vec<(Id, Rec)>` + the set of live `Id`s.
+3. Read the currently stored id set (snapshot read of the nodes,
+ components, component_sets, and styles tables). **Variable and
+ collection records are exempt from the sweep** — they come from
+ `import-variables`, not the file fetch, and must survive pulls.
+4. In one `wtx`: `upsert` every flattened record (unchanged records
+ short-circuit inside fold), `remove` every stored sweepable id not in
+ the live set, upsert `FileMeta`.
+5. Print a churn summary (added / changed / removed counts — obtained by
+ counting `upsert`/`remove` return values, which report the prior
+ record).
+
+**`watch`** — the polling loop:
+
+```
+loop:
+ meta = api.file_meta(key) # Tier 3: 50/min budget on Starter
+ if meta.last_touched_at != stored: # documented as content-modification time
+ pull()
+ sleep(interval) # default 10s, --interval to change
+```
+
+- A spurious trigger (touch without a real edit) costs one Tier-1 fetch
+ that produces zero churn — the design is self-healing, so the trigger
+ needs to be cheap, not perfect.
+- The comparison above is across endpoints: `meta.last_touched_at` comes
+ from `GET /v1/files/:key/meta` (Tier 3), while the stored watermark
+ (`FileMeta.last_modified`) was captured from `GET /v1/files/:key`'s
+ `lastModified` field (Tier 1) on the last `pull`. If the two fields ever
+ differ in format or precision, every `watch` start on a warm DB costs one
+ spurious Tier-1 pull that produces zero churn — self-healing within the
+ run, since the Watcher then keeps `last_touched_at` in memory and stops
+ re-triggering. See §9's manual live check for confirming the two fields
+ agree on a real file.
+- On 429: sleep `Retry-After` seconds, then resume (single-process poller;
+ no jitter). On network errors: exponential backoff capped at 5 min, keep
+ looping — `watch` must survive laptop sleep and flaky wifi. The same
+ discipline applies if the Tier-1 pull itself fails after a detected
+ change (429 → `Retry-After`; anything else → the same exponential
+ backoff), so a persistently failing pull doesn't hammer the Tier-1
+ budget.
+- `watch` performs an initial `pull` if the DB is empty or stale.
+
+Auth: personal access token from `FIGMA_TOKEN` (flag `--token` overrides).
+File identity: accept a bare key, a full `figma.com/design/...` URL, or a
+URL with `?node-id=`; node ids accept both `12:34` and `12-34` forms
+everywhere.
+
+### 6a. Variables & design tokens on a free plan
+
+The Variables REST endpoints (`/v1/files/:key/variables/local`, `/published`)
+are **Enterprise-only**, so figmog never calls them. Variables are supported
+through two complementary paths:
+
+**Path 1 — mirrored bindings + inference (always on, zero setup).** The
+file JSON annotates every variable-bound property with a `boundVariables`
+object, and Figma also bakes the *resolved* concrete value into the same
+node (the fill's color, the padding's number, …). Flatten collects every
+binding as `(json_path, variable_id)` on `NodeRec` (generic recursive
+scan), and the `bound_to` index inverts them. `figmog vars` then
+aggregates at read time: for each variable id — its binding sites
+(node, property path) and the *observed values* extracted from the
+consumers' `raw` JSON next to each binding. This yields
+`variable id → inferred value(s) + everywhere it's used`, which covers the
+default mode (values from non-default modes appear only where a frame
+explicitly overrides the mode — documented caveat).
+
+**Path 2 — authoritative import (optional).** `figmog import-variables
+` upserts `VariableRec` / `VariableCollectionRec` — full
+fidelity: collections, modes (light/dark), per-mode values, aliases,
+scopes. Accepted shapes: the Enterprise REST `variables/local` response,
+or the JSON produced by the standard free-plan escape hatch — a Figma
+plugin export (the Plugin API can read local variables on **any** plan;
+figmog's README ships a ~20-line "run in Figma's plugin console" snippet
+that dumps the REST-shaped JSON). Imports flow through the same
+`KeyedStream`, so re-imports diff incrementally like everything else and
+`figmog vars` prefers authoritative records over inference when present.
+
+A third source exists for paid seats only, noted for completeness and
+deliberately **not** built in v1: Figma's MCP servers expose
+`get_variable_defs`, but the desktop server needs a Dev/Full seat on a
+paid plan, the remote server allows Starter users only 6 tool calls per
+*month*, and the tool is selection-scoped rather than
+full-collections. Anyone with a paid seat can pipe its output into
+`import-variables` by hand; figmog never depends on MCP.
+
+Everything else the Tailwind layer needs is already mirrored at full
+fidelity in `raw` and queryable through the indexes: TypeStyle (font
+family/size/weight/line-height/letter-spacing), fills/strokes/effects,
+auto-layout (layoutMode, padding, itemSpacing, sizing), corner radii,
+grids, `characterStyleOverrides`/`styleOverrideTable` for mixed text, and
+component property definitions. Style *definitions* are not in the file
+JSON (the `styles` map is metadata only), so `figmog styles --values`
+derives each style's value from its `styled_by` consumers — e.g. a text
+style's TypeStyle from any TEXT node using it.
+
+## 7. CLI surface
+
+Read commands never touch the network. All output ordering is
+deterministic (sorted); `--json` emits machine-readable JSON on stdout.
+
+| command | reads | behavior |
+|---|---|---|
+| `figmog pull ` | — | sync now; prints churn summary |
+| `figmog watch [--interval 10]` | — | poll loop as above |
+| `figmog pages` | children of root | list CANVAS pages (id, name) |
+| `figmog tree [id] [--depth N]` | children + nodes | indented outline: `name [type] id`; root defaults to document |
+| `figmog get [--children]` | nodes (+children) | the full `raw` JSON of a node; `--children` inlines one level of child summaries |
+| `figmog search [-n 10]` | Bm25 + nodes | ranked hits: score, id, type, name, page, text snippet |
+| `figmog instances ` | components + instances_of | resolve arg to a component (by node id, global key, or unique name / name of a set ⇒ all its variants), list instance nodes |
+| `figmog components` | components + component_sets + children | design-system inventory: sets with their variant axes/options, standalone components |
+| `figmog styles [--type text\|fill\|effect\|grid] [--values]` | styles + styled_by (+nodes) | styles with usage counts; `--values` derives each style's definition from consumer nodes (§6a) |
+| `figmog uses ` | styled_by / bound_to + nodes | nodes using a style or bound to a variable |
+| `figmog vars [id]` | bound_to + nodes + variables | variables: authoritative records if imported, else inferred values + binding sites (§6a) |
+| `figmog import-variables ` | — | upsert variable/collection records (§6a Path 2) |
+| `figmog find --type TEXT [--page id]` | by_type + nodes | nodes by type, optional page filter |
+| `figmog status` | meta | file name, version, last modified, last synced, node count |
+
+DB location: `.figmog//` under the current directory (override
+`--db`). The CLI stores the last-used file key in `.figmog/current` so read
+commands don't need the file argument every time.
+
+## 8. Rust practices
+
+- **Errors:** `thiserror` error enums per module; no `unwrap`/`expect` on
+ network, parse, or user-input paths. `unwrap` is acceptable only where
+ fold itself panics by contract (store open, duplicate sink names) and in
+ tests. CLI exits nonzero with a one-line message on error; `--json` mode
+ errors are JSON on stderr.
+- **Testability by construction:** `FigmaApi` is a trait; `flatten` is
+ pure; `store::sync` takes pre-flattened records. The `watch` loop takes
+ its sleep function as a parameter. No test needs a network or a real
+ clock.
+- **Typed edges, dynamic core:** the response envelope (`name`, `version`,
+ `components`, `styles`, …) parses into serde structs; node trees parse as
+ `serde_json::Value` (the node schema is huge and mostly passed through
+ `raw`), with typed extraction only for the `NodeRec` fields. Exact field
+ names pinned against `figma/rest-api-spec` (OpenAPI) during
+ implementation.
+- **Determinism everywhere:** no `HashMap` iteration at any output
+ boundary (matches the repo's standing rule); sorted `Vec`s in records;
+ canonical JSON; CLI output sorted.
+- **Hygiene:** `cargo clippy -- -D warnings` and `cargo fmt --check`
+ clean; rustdoc on every public item; crate-level doc comment explaining
+ the pipeline (matching the style of the `search` example).
+- Workspace: scaffolded via `./scripts/new-project.sh figmog`; deps
+ `fold`, `serde`, `serde_json`, `postcard` (transitively via fold),
+ `ureq`, `clap`, `thiserror`. **No `ese`** (build speed), no tokio (ureq
+ is blocking; the watch loop is a plain thread).
+
+## 9. Test plan
+
+Fixtures are **synthetic** miniature Figma file JSONs (hand-written,
+~30–60 nodes) exercising: 3 pages, nested frames, TEXT nodes, a
+COMPONENT_SET with two axes (e.g. `Size` × `State`), variant COMPONENT
+children, INSTANCEs with `componentProperties` covering all four property
+types, standalone COMPONENT with non-variant properties, fill/text style
+refs, `boundVariables` refs at several depths, an invisible node — plus a
+separate REST-shaped variables-export fixture for `import-variables`. **No
+fixture may be derived from the g3d file** — it is client work and stays
+out of git (same policy as the repo's garden3d-corpus rule). The real file
+is used only for local manual verification.
+
+1. **Flatten unit tests** (`tests/flatten.rs`)
+ - ids/parents/child_index/page attribution correct for the whole fixture
+ - TEXT `characters` extracted; visibility default handling
+ - INSTANCE → `component_id` + sorted `component_properties` (all four
+ property types: VARIANT, BOOLEAN, TEXT, INSTANCE_SWAP)
+ - `property_definitions` canonical JSON on both COMPONENT and
+ COMPONENT_SET nodes
+ - style refs sorted and complete
+ - bound-variable scan finds bindings at every depth (a fill, a
+ TypeStyle field, a padding, a deeply nested property) and is
+ unaffected by unknown/new binding sites
+ - `raw` has no `children` key; parses back to JSON
+ - **Determinism:** flatten the same JSON twice (and a key-order-shuffled
+ copy of it) → identical postcard bytes per record.
+2. **Sync tests** (`tests/sync.rs`) — pipeline built with a test-only
+ delta-probe (a passthrough `Map` incrementing a `Rc>`)
+ between stream and sinks:
+ - **No-churn:** pull fixture, pull identical fixture again → probe count
+ 0 in the second `wtx`; all sink contents identical.
+ - **Minimal-churn diff:** fixture v1 → v2 (one rename, one node
+ deleted, one added, one instance's variant property changed) → probe
+ count equals exactly the expected retract+insert pairs; Bm25 no
+ longer matches the old name but matches the new one; deleted node
+ absent from `nodes`, `children`, `by_type`; new node present
+ everywhere applicable.
+ - **Removal cascade:** delete an INSTANCE → it disappears from
+ `instances_of`; delete a styled node → `styled_by` count drops.
+ - **Vanished-id sweep:** node present in store but absent from fetch is
+ removed even when nothing else changed.
+ - **Atomicity:** a flatten record that panics mid-`wtx` (injected) leaves
+ the store at the previous version (meta unchanged, old data readable).
+3. **Watch-loop tests** (unit, fake `FigmaApi` + recorded sleeps)
+ - unchanged metadata → no `file()` call
+ - changed metadata → exactly one `file()` call, then quiescent
+ - 429 with `Retry-After: 30` → recorded sleep ≥ 30s, loop continues
+ - network error → backoff grows, caps, loop continues
+4. **Design-token tests**
+ - `vars` inference: fixture with a color variable bound to fills on two
+ nodes → inferred value equals the baked-in color, both binding sites
+ listed
+ - `import-variables` round trip: import a REST-shaped export (two
+ collections, two modes, an alias) → `vars` shows authoritative
+ values incl. per-mode; re-import of identical file → zero churn
+ (delta probe); import with one changed mode value → minimal churn
+ - `styles --values`: text style value derived from a consumer TEXT
+ node's TypeStyle; fill style from a consumer's fills
+5. **CLI smoke tests** (`tests/cli.rs`, via `assert_cmd` or equivalent)
+ - build a DB from the fixture, run each read command, snapshot-assert
+ stdout (`--json` mode: parse and assert structurally)
+ - URL/key/node-id argument parsing (`12-34` ⇒ `12:34`, full URLs)
+6. **Manual live check** (documented in the crate README, not CI): `FIGMA_TOKEN=… figmog pull `, then `figmog components`,
+ `figmog search`, timing note. Acceptance: read commands return in
+ milliseconds on the real file. Also confirm `last_touched_at` (meta) equals
+ `lastModified` (file) on the real file, since `watch` compares them
+ across two different endpoints (§6).
+
+Full-feature test run (`cargo test -p figmog`) must pass before the
+milestone is called done; `-p figmog` doesn't build ese, so iteration is
+already fast.
+
+## 10. Risks & open items
+
+- **Change-detection field pinned:** `GET /v1/files/:key/meta` returns
+ `last_touched_at`, documented in the OpenAPI spec as "the UTC ISO 8601
+ time at which the file content was last modified". Fallback if it
+ proves noisy: the `versions` endpoint (Tier 2).
+- **File size:** GET file for a large handoff file can be tens of MB;
+ `ureq` reads it streaming into `serde_json`. If flatten+parse of the
+ real file is slow (>2–3s), acceptable — it's off the read path.
+- **Instance sub-tree contents:** Figma serializes instances' overridden
+ subtrees as normal children; they mirror like any node. Overrides beyond
+ the serialized tree are not resolved (documented).
+- **Branching files:** `branch_data` ignored in v1; mirroring a branch =
+ mirroring its own file key.
+
+## 11. v2: `figmog serve` — the MCP server
+
+The point of the whole project: agents talk MCP, so the mirror gets an MCP
+face. **Revision of the v1 non-goal sketch:** this is a `serve` subcommand
+of the same binary, not a second binary — fjall is single-writer, so a
+standalone MCP process would fight `figmog watch` for the store lock.
+`figmog serve` is therefore **one process that owns the store**: an MCP
+stdio server with the sync loop integrated. Agents get always-fresh reads;
+there is nothing else to run.
+
+### Architecture
+
+```
+stdin ──▶ reader thread ──▶ mpsc ──▶ main loop ──▶ stdout (responses)
+ │ recv_timeout(next poll tick)
+ ├─ on line: JSON-RPC dispatch → query::*
+ └─ on timeout: Watcher::tick → maybe pull
+```
+
+- **`query.rs` (refactor):** the read logic currently inlined in the CLI's
+ `cmd_*` printers moves into pure functions that take readers and return
+ `serde_json::Value` — `query::status`, `query::pages`, `query::tree`,
+ `query::node`, `query::find`, `query::search`, `query::instances`,
+ `query::components`, `query::styles`, `query::uses`, `query::vars`. The
+ CLI commands become thin printers over `query::*` (this also retires the
+ deferred "json/human boilerplate" debt); MCP tools call the same
+ functions. One source of truth for every answer.
+- **`mcp.rs`:** minimal JSON-RPC 2.0 over newline-delimited stdio. Handles
+ `initialize` (echo the client's `protocolVersion`; `capabilities:
+ {tools: {}}`; `serverInfo {name: "figmog", version}`),
+ `notifications/initialized` (ignore), `ping`, `tools/list`,
+ `tools/call`. Everything else → JSON-RPC `-32601`. Malformed JSON →
+ `-32700` with `id: null`. Logging to stderr only; stdout carries nothing
+ but protocol frames.
+- **`serve.rs`:** the loop above. Store owned by the main thread (no
+ `Send` requirements on fold types). Poll ticks run only between
+ requests; a pull blocks request handling for its duration (documented —
+ seconds at worst, and only when the file actually changed).
+
+### Relationship to Figma's official MCP server (binding; revised for v3)
+
+**Positioning (v3 decision): figmog is the ONLY Figma MCP an agent
+connects to** — a cached proxy in front of Figma's native desktop server,
+plus the local mirror's own query tools. Within the one server, the
+namespace rule keeps every call unambiguous:
+
+1. **Native-named tools are always proxied.** Tools discovered from the
+ upstream desktop server (`get_design_context`, `get_screenshot`,
+ `get_metadata`, `get_variable_defs`, …) are re-exposed verbatim and
+ answered by the upstream (through the cache) with native semantics and
+ output formats — figmog never impersonates them with its own data.
+2. **`figmog_*` tools are always local.** The mirror's query tools answer
+ instantly from the store at zero API cost.
+3. **Server-level steering:** the `initialize` result's `instructions`
+ field carries, verbatim: "figmog is your Figma server: a local,
+ instant mirror of one Figma file plus a cached proxy to Figma's native
+ capabilities. Call figmog for everything Figma-related. figmog_* tools
+ answer from the local mirror at zero API cost; native-named tools
+ (get_*, …) go to Figma, cached by file version where possible."
+
+This targets **paid Dev/Full seats** (the desktop server's requirement).
+The free-plan-only paths (plugin-console variables export, inference as
+primary) remain in the code as fallbacks but are no longer the design
+center.
+
+### Tools
+
+Read tools mirror the CLI one-to-one, each returning the `query::*` JSON
+as an MCP text content block. Names and inputs:
+
+| tool | input schema (all fields optional unless noted) |
+|---|---|
+| `figmog_status` | — |
+| `figmog_pages` | — |
+| `figmog_tree` | `id`, `depth` (integer) |
+| `figmog_node` | `id` (required), `children` (bool) |
+| `figmog_find` | `type` (required), `page` |
+| `figmog_search` | `query` (required), `limit` (integer, default 10) |
+| `figmog_instances` | `target` (required) |
+| `figmog_components` | — |
+| `figmog_styles` | `type`, `values` (bool) |
+| `figmog_uses` | `id` (required) |
+| `figmog_vars` | `id` |
+| `figmog_sync` | — (forces one pull; returns churn; the only tool that spends rate budget) |
+
+**Whole-file structural queries** (the local mirror's unfair advantage —
+each is a full-file answer no rate-limited API surface could offer; all
+are read-only scans/joins over existing sinks, and each gets a matching
+CLI subcommand so the CLI/tool one-to-one rule holds):
+
+| tool / CLI command | input | answer |
+|---|---|---|
+| `figmog_stats` / `figmog stats` | — | node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth |
+| `figmog_path` / `figmog path ` | `id` (required) | ancestor chain root→node as `[{id, name, type}]` |
+| `figmog_text` / `figmog text [--page id]` | `page` | every TEXT node's (id, characters, page_id), sorted by id |
+| `figmog_where` / `figmog where --pointer /p --equals ` | `pointer` (required, RFC 6901 into node `raw`), `equals` (JSON value; omitted ⇒ "pointer exists"), `page` | matching `[{id, name, type, page_id, value}]`, sorted by id |
+| `figmog_at` / `figmog at --x N --y N` | `x`, `y` (required, floats) | nodes whose `abs_bounds` contain the point, sorted by area ascending (deepest/smallest first) |
+
+`figmog_where`'s `equals` compares the pointed-at value by JSON equality
+(numbers per serde_json semantics). Full-node scans are acceptable: they
+run against the local store, not Figma.
+
+Tool-level failures (unknown node, no mirror, sync error) return an MCP
+result with `isError: true` and the message as text — JSON-RPC errors are
+reserved for protocol-level problems.
+
+### CLI surface
+
+`figmog serve [file] [--interval N] [--no-watch]` — `--no-watch` disables
+the poll loop (offline/fixture use; also what tests run). File/db
+resolution identical to the other commands.
+
+### Testing
+
+- **Protocol unit tests** (`mcp.rs`): dispatch table over scripted
+ request values — initialize echo (incl. the steering `instructions`),
+ tools/list shape (17 tools, valid JSON-Schema inputs), unknown method
+ `-32601`, parse error `-32700`, tools/call routing incl. `isError` on a
+ bad tool name.
+- **`query` equivalence:** the CLI smoke tests keep passing unchanged
+ after the refactor (the printers now consume `query::*`), proving the
+ refactor moved logic without changing it.
+- **End-to-end serve test** (`tests/serve.rs`): build a fixture DB via
+ `pull --from-file`, spawn `figmog serve --no-watch --db …` as a child
+ process, drive initialize → tools/list → several tools/call over
+ stdin/stdout, assert JSON-RPC ids, tool result contents (e.g.
+ `figma_search` finds node 1:2), and `isError` for an unknown node.
+- **Live check addendum:** point Claude Code at the server
+ (`claude mcp add figmog -- /figmog serve `) and ask it about
+ the file.
+
+### Non-goals (v2)
+
+MCP resources/prompts capabilities; HTTP/SSE transports for *our* server;
+multi-file serving; auth on the socket (stdio only, inherits process
+trust).
+
+## 12. v3: the cached proxy
+
+figmog becomes the only Figma MCP an agent sees: local `figmog_*` tools
+plus a verbatim passthrough of the native desktop server's tools, with a
+version-keyed response cache. Targets paid Dev/Full seats; requires the
+Figma desktop app's Dev Mode MCP server (streamable HTTP at
+`http://127.0.0.1:3845/mcp` by default).
+
+### Upstream client (`upstream.rs`)
+
+- `trait UpstreamMcp { fn initialize(&mut self) -> Result<(), UpstreamError>; fn tools(&self) -> &[Value]; fn call(&mut self, name: &str, args: &Value) -> Result; }`
+ plus `HttpUpstream` (ureq POST of JSON-RPC frames; accept both
+ `application/json` bodies and single-event `text/event-stream`
+ responses, extracting the `data:` JSON; carry the
+ `Mcp-Session-Id` header if the server issues one) and a scripted fake
+ for tests. `--upstream ` overrides the default;
+ `--no-upstream` disables proxying entirely.
+- Startup: probe + MCP handshake; on failure, serve local tools only,
+ log one stderr line, and report `upstream: "unreachable"` in
+ `figmog_status`. No mid-session re-probe in v3 (restart to attach —
+ documented).
+
+### Registry merge
+
+`tools/list` = the 17 local `figmog_*` tools followed by every upstream
+tool verbatim (name, description, inputSchema passed through; description
+prefixed "[via Figma desktop] "). Name collisions are impossible by the
+namespace rule; if an upstream tool ever arrives named `figmog_*`, drop
+it and log. `tools/call` routes by name: local registry first, else
+upstream.
+
+### Cache
+
+- New record kind: `Id::ProxyCache(String /*key hash*/)`,
+ `Rec::ProxyCache { key_hash, tool, args_canonical, file_version,
+ content: String /*canonical JSON of the MCP result content*/ }`, stored
+ through the same stream into a `proxy_cache` Table sink.
+- **Cacheable** = tool name starts `get_` or `list_` AND the arguments
+ contain an explicit node id (selection-based calls are invisible to the
+ cache and always forwarded). Key = hash(tool + canonical args); a hit
+ requires `file_version == current FileMeta.version`.
+- **Eviction:** during `sync`, when the file version changes, stale
+ `ProxyCache` rows (any whose `file_version` differs from the incoming
+ version) join the sweep. Manually imported variables remain
+ sweep-exempt; the cache is not.
+- **Writes:** any non-cacheable upstream call that is not `get_`/`list_`
+ (e.g. `add_code_connect_map`, `send_code_connect_mappings`) is
+ forwarded uncached and, on success, triggers an immediate meta poll so
+ upstream-originated edits reach the mirror without waiting for the
+ next tick.
+
+### CLI parity (1:1 via mechanism)
+
+The engine exposes everything; the CLI can invoke anything:
+- `figmog tools` — the merged tool list (local + upstream, with source
+ and cacheability flags).
+- `figmog call [--args '']` — invoke any tool by name
+ through the same dispatch the MCP server uses (local tools included).
+Bespoke subcommands for upstream tools are deliberately NOT added —
+Figma's tool list churns; the generic mechanism is the stable 1:1
+surface.
+
+### Enterprise variables (opportunistic)
+
+`pull` additionally calls `GET /v1/files/:key/variables/local` (Tier 2/
+Enterprise): on success its records flow through
+`parse_variables_export` into the same sync **and become sweepable for
+that pull** (API-provided variables are file state); on 403/404 the call
+is skipped silently and the v1 behavior (import/inference, sweep-exempt)
+holds unchanged.
+
+### Testing
+
+- Upstream client unit tests against a scripted fake; one in-process HTTP
+ fake (std `TcpListener` serving canned JSON-RPC responses, no new
+ deps) exercising `HttpUpstream` end to end incl. the SSE-style body.
+- Registry merge + routing + cache hit/miss/eviction unit tests (fake
+ upstream, fixture store; assert the second identical `get_*` call with
+ a nodeId never reaches the fake, and a version bump evicts).
+- e2e: serve with `--no-upstream` keeps the v2 behavior (existing tests);
+ one e2e with the in-process HTTP fake upstream asserts a proxied tool
+ appears in tools/list and round-trips.
+
+### Non-goals (v3)
+
+Proxying the remote server (OAuth); mid-session upstream re-attach /
+`listChanged` notifications; caching selection-based calls; multi-file.
+
+## 13. `figmog bench` — the load-test demo
+
+One self-contained command that makes the value proposition measurable:
+local reads at memory speed against a rate-limited API that allows ~10
+file requests per minute.
+
+`figmog bench [FILE] [--nodes N] [--calls M] [--api-calls K] [--skip-api]
+[--json] [--keep]`
+(defaults: N=10000, M=5000, K=5; `--keep` leaves the temp store on disk
+and prints its path).
+
+**Two sources.** With no `FILE` argument, the corpus is synthetic
+(deterministic, phase 1 below). With a Figma URL/key, the corpus is the
+real file: fetched once via `FIGMA_TOKEN` (exactly one Tier-1 call — the
+no-churn re-pull phase reuses the same in-memory JSON rather than
+fetching twice), and the load-test query mix derives its parameters from
+the flattened data itself: search words sampled from real layer
+names/text, node ids from real ids, the instances target from a real
+component name (each falling back gracefully when a category is absent).
+The same derivation runs in synthetic mode, so the two modes share one
+code path.
+
+**API comparison phase** (real-file mode only, unless `--skip-api`):
+after the serve load, issue K sequential calls to
+`GET /v1/files/:key/nodes?ids=` — the native API's closest
+equivalent of `figmog_node` — timing each, plus one Tier-3
+`GET /v1/files/:key/meta` for reference. K defaults to 5 because this
+spends the user's real Tier-1 budget (~10/min); a 429 is recorded (with
+its Retry-After) and ends the phase gracefully, reporting whatever was
+measured. The report then shows figmog vs API latency side by side and
+computes the budget math: how long the M-call load test would take at
+the API's rate limit versus figmog's measured wall time.
+
+### Phases (all timed, all reported)
+
+1. **Corpus** — generate a deterministic synthetic Figma file JSON with N
+ nodes: pages of auto-layout frames, TEXT nodes whose characters are
+ drawn from a fixed word list (so BM25 has real queries), one
+ COMPONENT_SET with variants plus INSTANCE nodes referencing them,
+ fill/text styles, and `boundVariables` bindings. Determinism: a seeded
+ LCG in the generator, no wall-clock, no `rand` dep — the same `--nodes`
+ always yields byte-identical JSON. Generator lives in the crate
+ (`src/bench.rs` or a `corpus` module) and is unit-testable
+ (node count exact, determinism byte-checked).
+2. **Cold sync** — flatten + `sync` the corpus into a temp store via the
+ library (not a child process): report flatten ms, sync ms, records/s.
+3. **No-churn re-pull** — sync the identical corpus again: report ms and
+ assert-in-code churn is zero (the engine's headline invariant, timed).
+4. **Serve load** — spawn `current_exe()` as
+ `serve --no-upstream --no-watch --db `, complete the MCP
+ handshake, then issue M tools/call frames in a fixed rotating mix
+ (`figmog_search` with rotating corpus words, `figmog_node`,
+ `figmog_where`, `figmog_stats`, `figmog_tree` (depth 2),
+ `figmog_instances`), measuring wall time per request (write→response
+ line). Sequential over one stdio pipe — that matches the server's
+ single-threaded loop, so the numbers are honest.
+
+### Report
+
+Per-tool table: calls, p50 / p95 / p99 / max (ms), plus overall
+sustained req/s and total wall time. In real-file mode with the API
+phase: an additional side-by-side block — `figmog_node p50` vs
+`API /nodes p50`, the speedup factor, and the budget line ("the M-call
+load test at ~10 req/min would take ≈X; figmog: Ts"). `--json` emits one
+JSON object with the same fields (stdout purity as elsewhere: human
+table OR json, never both). Ends with the headline comparison in both
+modes.
+
+### Constraints
+
+No new dependencies. Percentiles via sort. `Instant`-based timing only
+(no SystemTime in the measurement path). Synthetic mode must not touch
+the network at all; real-file mode makes exactly 1 Tier-1 file fetch, an
+opportunistic variables call, and (unless `--skip-api`) K+1 comparison
+calls — the report states every API call it spent. Temp dir cleaned
+unless `--keep`. Exit nonzero if any phase fails or any tool call
+returns `isError` (a graceful 429 in the comparison phase is a recorded
+result, not a failure).
+
+### Interactive mode (`--interactive`)
+
+`figmog bench [FILE] --interactive` runs the same setup (corpus or real
+file → cold sync → spawn serve child) and then, instead of the automated
+phases, drops into a REPL on the user's terminal so requests are visible
+as they fire:
+
+- **Tool shorthands** mapping to the local tools with light arg parsing:
+ `search `, `node [children]`, `tree [id] [depth]`,
+ `find [page]`, `where [value]`, `stats`, `path `,
+ `text [page]`, `at `, `instances `, `components`,
+ `styles [type]`, `uses `, `vars [id]`, `pages`, `status`. Each
+ prints one aligned line: sequence number, tool, arg summary, latency in
+ ms, and (dim) a one-line result digest (hit count / name / isError).
+- **`run N`** — fire N requests of the derived mixed workload, streaming
+ one line per request in real time, then print the session percentile
+ table for the burst.
+- **`api node ` / `api meta`** — real-file mode only: fire one actual
+ Figma API call (`/nodes` or `/meta`), timed the same way, each line
+ labeled with the API cost it spent. The live side-by-side is the demo's
+ centerpiece; 429s print their Retry-After and do not exit.
+- **`call `** — raw escape hatch (works for proxied
+ tools too when an upstream is attached).
+- **`report`** — cumulative per-tool percentiles for everything fired
+ this session; **`help`**; **`quit`**/EOF exits cleanly (child reaped).
+
+Colors: raw ANSI escapes only (no deps), emitted only when stdout is a
+terminal (`IsTerminal`); latency lines green under 10ms, yellow under
+100ms, red above; errors red. Non-TTY stdout gets plain text. The
+interactive mode is human-only: `--json` combined with `--interactive`
+is a usage error. The one-shot mode is unchanged (CI/e2e cover it);
+interactive gets a scripted e2e (commands piped via stdin, non-TTY plain
+output asserted, clean exit on EOF).
+
+### Non-goals
+
+Concurrent client simulation (stdio is one pipe; the server is
+single-threaded by design); benchmarking the proxy path (network-bound,
+not ours to measure); measuring Figma's rate limit itself (the
+comparison phase measures API *latency* with K small calls; the
+~10/min budget number is documented, never probed to exhaustion);
+readline niceties (history/completion — plain stdin lines are enough
+for a demo REPL).
+
+## 14. v4: multi-file serve
+
+Agents address Figma by URL — a server bound to one file at startup
+breaks that habit. v4 makes `figmog serve` a multi-file server; the
+per-file-key store layout (`.figmog//db`) has anticipated this
+since v1.
+
+### Surface
+
+- `figmog serve [FILE]...` — zero or more files at startup. Zero is
+ valid: the server starts empty and mirrors files as agents reference
+ them. Each startup FILE is pulled if its store is empty.
+- **Every local tool gains an optional `file` argument** (URL or key,
+ parsed with `ident::parse_file_ref`). Resolution: explicit `file` arg →
+ that mirror (auto-opening it if unknown, which spends one Tier-1
+ pull); omitted → the default file (first startup FILE, else the single
+ mirrored file, else an isError naming `figmog_files`/`figmog_open`).
+- New tools: `figmog_open {file}` — mirror a file now (one Tier-1 pull;
+ returns churn + node count), and `figmog_files` — list mirrored files
+ (key, name, version, nodes, last synced, default flag). Local tool
+ count becomes 19.
+- The steering `instructions` text is extended with one sentence: "Pass
+ the Figma file URL as the `file` argument when you have one; figmog
+ mirrors files on first reference."
+
+### Mechanics
+
+- **Sessions:** each mirrored file is a `FileSession` whose store lives
+ captured inside boxed closures (`dispatch(tool, args)`,
+ `pull()`, `watermark()`) — the established answer to the unnameable
+ pipeline type; sessions live in a `Vec` keyed by file key, ordered by
+ open time (first = default). Opening a session = the do_pull-equivalent
+ sequence at a concrete `open_store!` site inside the closure factory.
+- **Watch:** one `Watcher` + backoff per session; the tick visits
+ sessions round-robin (one meta poll per tick, deadline = interval /
+ live-session-count, floor 2s) so total Tier-3 spend stays ≈ one file's
+ worth per interval times the file count — well inside 50–150/min for
+ dozens of files. `--no-watch` unchanged.
+- **Cache / eviction:** unchanged — each session's store carries its own
+ `proxy_cache`, evicted by that file's own version changes.
+- **CLI:** unchanged single-file semantics (`--db`/`.figmog/current`);
+ multi-file is a serve capability. `figmog call`/`tools` against a
+ running multi-file config still address one store.
+- **Proxied tools caveat (documented, not fixed):** the desktop server
+ operates on the file open in the Figma app; the `file` argument does
+ not route proxied tools. README states this plainly.
+
+### Non-goals (v4)
+
+Cross-file queries (joins/search spanning mirrors); mirroring whole
+teams/projects by enumeration; eviction of idle sessions (a session
+opened stays open for the process lifetime); CLI multi-file addressing.
+
+### Testing
+
+- Session-resolution unit tests (explicit file, default, unknown-file
+ isError text, auto-open path with a scripted pull closure).
+- Serve e2e: start with no FILE against two pre-built fixture stores'
+ keys… (stores are per-key temp dirs; the e2e uses `--from-file`-built
+ stores by pre-creating them under a temp `.figmog` root and passing
+ `--figmog-root ` — add that hidden flag for testability, default
+ `.figmog`), then: `figmog_files` lists both, a tool with `file` routes
+ to the right mirror (distinct fixture names prove it), omitted `file`
+ errors when two mirrors exist and no default was given, `figmog_open`
+ with `--from-file`-shaped… (network-free e2e: `figmog_open` is
+ network-only; e2e covers its isError on missing token instead).
+
+## 15. v5: the remote upstream (mcp.figma.com) — BLOCKED (2026-08-16)
+
+> **Status: blocked by Figma policy, not engineering.** Verified via
+> Figma community threads: (a) personal/plan access tokens are rejected
+> at mcp.figma.com — OAuth is the only auth; (b) remote-MCP access is
+> allowlisted to clients in Figma's MCP Catalog (VS Code, Cursor, Claude
+> Code, Codex) — custom clients cannot request the `mcp:connect` scope
+> and dynamic client registration returns 403. figmog therefore cannot
+> authenticate as itself, and impersonating a catalog client's identity
+> would circumvent Figma's access control — out of the question. The
+> design below stands ready if Figma ever opens DCR/PAT auth; until
+> then the desktop server is the only proxyable upstream, and remote-only
+> capabilities are candidates for native REST-backed equivalents instead.
+
+A second upstream flavor alongside the desktop server. The remote server
+is a better proxy citizen than desktop — its tools take explicit
+URLs/nodeIds per call (no selection), so proxied tools route per-file
+like the local ones, erasing §12's open-file caveat — and it adds
+remote-only tools (search_design_system, use_figma, whoami,
+download_assets, generate_diagram, …). Its calls cost Tier-1-equivalent
+per-minute budget on paid seats (6/month on Starter → effectively
+paid-seat-only, consistent with the design center), which makes the
+version-keyed cache genuinely valuable.
+
+### Auth (the whole cost)
+
+MCP OAuth, std-only:
+- Discovery: on 401, read `WWW-Authenticate` /
+ `/.well-known/oauth-protected-resource`, then the authorization
+ server's metadata (`/.well-known/oauth-authorization-server`).
+- Dynamic client registration at the advertised registration endpoint
+ (public client, PKCE).
+- Browser flow: local `TcpListener` on an ephemeral port serves the
+ redirect; `open`/`xdg-open` launches the authorization URL; PKCE
+ verifier from `/dev/urandom`, S256 challenge via a vendored ~100-line
+ SHA-256 (well-known constants; unit-tested against published test
+ vectors). State parameter checked.
+- Tokens persisted at `/auth.json` (0600), refresh-token
+ flow on 401/expiry; failures degrade to "remote upstream
+ unauthenticated" status (local tools unaffected).
+
+### Surface
+
+- `--upstream` accepts the remote URL; `--remote` sugar for
+ `--upstream https://mcp.figma.com/mcp`. Desktop and remote are the
+ same `UpstreamMcp` path — the OAuth layer is an `HttpUpstream`
+ concern activated when a request meets a 401 challenge (desktop never
+ does). `figmog login` CLI command runs the flow standalone;
+ `figmog serve` triggers it lazily on first challenged request
+ (browser opens once; stderr explains).
+- Registry/routing/caching per §12 unchanged; remote tool descriptions
+ prefixed "[via Figma remote] ". Cacheable rule unchanged (get_/list_
+ + explicit node id) — remote's URL-addressed args satisfy it
+ naturally; `use_figma`/creates are writes (uncached, meta-poll
+ trigger).
+- `figmog_status.upstream` distinguishes `connected (desktop)` /
+ `connected (remote)` / `unauthenticated (remote)` / `unreachable` /
+ `disabled`.
+
+### Non-goals (v5)
+
+Multiple simultaneous upstreams (one at a time via --upstream); token
+encryption beyond file permissions; headless/device-code auth flows.
+
+### Testing
+
+SHA-256 against FIPS test vectors; PKCE challenge known-answer test;
+OAuth state machine against a scripted in-process HTTP fake (challenge →
+discovery → registration → token exchange → authenticated retry →
+refresh-on-401); no live-network tests (manual live check documented).
diff --git a/docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md b/docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md
new file mode 100644
index 0000000..46523af
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md
@@ -0,0 +1,132 @@
+# figmog standalone repo — migration & overhaul design
+
+**Date:** 2026-08-16
+**Status:** Approved in discussion; this document is the binding record.
+**From:** `sanctuarycomputer/clog` branch `worktree-figmog`, `examples/figmog`
+**To:** new repo `sanctuarycomputer/figmog`, crate at root
+
+## 1. Goal
+
+figmog graduates from a hackathon example crate to a standalone,
+distributable tool: its own repo, `fold` pulled via git, dead weight
+removed, and downloadable binaries on GitHub Releases. Homebrew is
+explicitly deferred (no tap, no formula — revisit later).
+
+## 2. New repo shape
+
+```
+figmog/
+ Cargo.toml — single crate (bin + lib), version 0.1.0
+ src/ — moved wholesale from examples/figmog/src (minus cuts)
+ tests/ — moved wholesale (minus cuts)
+ docs/
+ SPEC.md — consolidated current-state spec (no v1..v5 archaeology)
+ history/… — the old build-design spec + plans, verbatim, read-only
+ README.md — rewritten standalone (install via release binary,
+ quick start, MCP setup, tool tables, limitations)
+ CLAUDE.md — figmog's standing rules (see §6)
+ LICENSE — MIT, covering figmog's own code
+ .github/workflows/
+ ci.yml — test + clippy(-D warnings, --no-deps) + fmt on push/PR
+ release.yml — see §5
+```
+
+History: clean start — one import commit whose message records provenance
+(source repo, branch, PR #1 URL). No filter-repo.
+
+## 3. Dependencies
+
+```toml
+fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }
+```
+
+- `anny` rides along via fold's internal path-dep; everything else stays
+ crates.io as today (serde, serde_json, ureq 2, clap 4, thiserror 2;
+ dev: tempfile, assert_cmd, postcard).
+- The rev is pinned and bumped deliberately. Documented fallback: retarget
+ to the `sanctuarycomputer/clog` fork if upstream moves or archives.
+- **License gate:** `fold` currently has no license (bogkit repo has no
+ LICENSE file, fold's Cargo.toml no license field). figmog's own code is
+ MIT, and building locally is fine — but **publishing release binaries
+ that embed fold waits until upstream adds a license**. The release
+ workflow lands ready; the first public (non-draft) release is a manual
+ act taken after that clears. README states this plainly until resolved.
+
+## 4. Cuts and shakeout
+
+**Removed surfaces (approved):**
+- `bench.rs`, `repl.rs`, the corpus generator, `--interactive` — the whole
+ bench/demo apparatus (~2.5k lines) and its spec §13 (moves to history).
+- `figmog watch` CLI command (serve owns sync; `pull` remains for
+ one-shots). Its helpers survive only where serve/sessions use them.
+- Human-mode CLI output: every read command emits JSON only; the `--json`
+ flag disappears (JSON is the only mode); errors are JSON on stderr.
+ `serve`'s MCP protocol output is unchanged (it was already pure frames).
+
+**Kept:** pull (`--from-file`, `--fresh`), all read commands incl. the
+structural pack, import-variables + inference (variables fallbacks stay),
+tools/call, serve (multi-file + desktop proxy + Enterprise variables).
+
+**Debt paid during migration** (the deferred-minors ledger, plus fresh
+audit):
+- `variable_edges` dedup made real or removed (comment currently
+ overclaims); `merge_registry` dedup by actual local names, not prefix;
+ hoist the namespace check above the per-proxied-call `rtx`;
+ `cache::store` surfaces serialize errors; wrong-type tool args say
+ "expected string, got number" instead of "missing"; `--interval`
+ overflow clamped; serve's stdout writes tolerate a vanished client
+ (no broken-pipe panic); `pub`/`pub(crate)` visibility mismatches fixed;
+ `obj_map` clone pass removed; README carries the Tier-3 poll budget.
+- Post-cut dead-code sweep: removing printers/bench orphans helpers —
+ `cargo clippy` dead-code warnings + a manual pass over `pub(crate)`
+ items with no remaining callers.
+- `cli.rs` (~1.4k lines) splits: `cli/mod.rs` (clap + dispatch),
+ `cli/pull.rs`, `cli/read.rs`, `cli/call.rs`; printing collapses to
+ `serde_json::to_string_pretty` at one seam.
+
+## 5. Release binaries (GitHub Releases; no Homebrew)
+
+`release.yml`: on tag `v*` — matrix build `aarch64-apple-darwin`,
+`x86_64-apple-darwin`, `x86_64-unknown-linux-gnu` (`cargo build
+--release`), strip, tar.gz as `figmog--.tar.gz`,
+generate `SHA256SUMS`, create a **draft** GitHub Release with the
+artifacts attached. Publishing the draft is manual (and gated per §3
+until fold is licensed). README's install section: download, untar,
+`chmod +x`, optionally `xattr -d com.apple.quarantine` note for
+unsigned macOS binaries (no codesigning/notarization in v1 — documented
+limitation).
+
+## 6. New-repo CLAUDE.md (standing rules)
+
+- Determinism: sorted output at every boundary; no HashMap iteration at
+ output boundaries; serde_json never with `preserve_order`.
+- On-disk schema: sink names frozen; `Id`/`Rec` enums append-only
+ (postcard variant indices).
+- fold/bogkit is upstream: never vendored, never patched; consume the
+ pinned git dep's public API only.
+- Gates for any change: `cargo test`, `cargo clippy --no-deps -- -D
+ warnings`, `cargo fmt --check`.
+- No new dependencies without a written justification in the PR.
+- Fixtures are synthetic only — nothing derived from real client files.
+
+## 7. Sequencing
+
+1. In-flight multi-file serve work completes in the current repo
+ (Task 1 done; review running; Task 2 e2e/docs next) — it migrates
+ wholesale.
+2. Adversarial whole-branch review of the multi-file milestone; push to
+ PR #1 (final state of the example-crate era).
+3. New repo created (**user confirms creation of
+ `sanctuarycomputer/figmog` before any `gh repo create` runs**).
+4. Migration executes per this spec: import → git-dep switch → cuts →
+ debt payment → module split → SPEC.md consolidation → CI + release
+ workflow → tag `v0.1.0` draft release.
+5. PR #1 gains a "graduated to sanctuarycomputer/figmog" note; it stays
+ open as the hackathon artifact (upstream submission remains a separate,
+ user-initiated act from the fork).
+
+## 8. Non-goals
+
+Homebrew tap/formula (deferred); codesigning/notarization; crates.io
+publication (fold isn't on crates.io, so figmog can't be);
+Windows builds; history-preserving migration.
diff --git a/examples/figmog/Cargo.toml b/examples/figmog/Cargo.toml
new file mode 100644
index 0000000..cc43305
--- /dev/null
+++ b/examples/figmog/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "figmog"
+version = "0.0.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+fold = { path = "../../fold" }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+ureq = { version = "2", features = ["json"] }
+clap = { version = "4", features = ["derive"] }
+thiserror = "2"
+
+[dev-dependencies]
+tempfile = "3"
+assert_cmd = "2"
+postcard = { version = "1", features = ["use-std"] }
diff --git a/examples/figmog/README.md b/examples/figmog/README.md
new file mode 100644
index 0000000..3ec0d3a
--- /dev/null
+++ b/examples/figmog/README.md
@@ -0,0 +1,589 @@
+# figmog
+
+A fold-backed local mirror of one Figma file: `pull` fetches it once and
+keeps materialized indexes in a fold database, so every read after that —
+search, tree walks, component/style/variable queries — answers from local
+storage in milliseconds, spending zero Figma API calls and hitting zero
+rate limits.
+
+## Quick start
+
+```console
+$ export FIGMA_TOKEN=figd_… # figma.com → settings → security → personal access tokens
+$ cargo run -p figmog -- pull "https://www.figma.com/design//"
+$ cargo run -p figmog -- search "pricing card"
+$ cargo run -p figmog -- watch # keep it fresh in another terminal
+```
+
+After the first `pull`, figmog remembers the file key (in `.figmog/current`
+under the current directory), so every later command — including `watch`
+and all the read commands below — can drop the file argument.
+
+## Commands
+
+Read commands never touch the network: they open the local store and read
+one snapshot. `--json` (global) emits machine-readable JSON on stdout
+instead of the human-readable format; `--db ` (global) overrides the
+store location (default `.figmog//db`).
+
+| command | reads | behavior |
+|---|---|---|
+| `figmog pull [file] [--from-file ] [--fresh]` | — | sync now; prints a churn summary (`+added ~changed -removed`). `file` is optional after the first pull. `--from-file` ingests a saved `GET /v1/files/:key` response instead of the network (offline ingestion, and what keeps the CLI tests hermetic). `--fresh` wipes the store **and the proxy response cache** and rebuilds from scratch. |
+| `figmog watch [file] [--interval N]` | — | poll loop: cheap metadata check every `N` seconds (default 10), full pull only on an actual change |
+| `figmog serve [file] [--interval N] [--no-watch] [--upstream ] [--no-upstream]` | — | MCP stdio server (see "Use from agents (MCP)" below); `--no-watch` disables the poll loop for a read-only, offline server; `--no-upstream` disables the cached proxy to Figma's native desktop MCP server |
+| `figmog tools [--upstream ] [--no-upstream]` | — | list every tool `figmog serve` would expose for this mirror: name, source (`local`/`upstream`), and whether it's cache-capable |
+| `figmog call [--args ''] [--upstream ] [--no-upstream]` | — | invoke any tool by name through the same dispatch `figmog serve` uses — local `figmog_*` tools and, when attached, any upstream tool |
+| `figmog status` | meta + nodes | file name, version, last modified, node count |
+| `figmog pages` | by_type + nodes | list CANVAS pages (id, name) |
+| `figmog tree [id] [--depth N]` | children + nodes (+ by_type to find the root) | indented outline: `name [type] id`; root defaults to the DOCUMENT node |
+| `figmog get [--children]` | nodes (+ children) | the full `raw` JSON of a node; `--children` inlines one level of child summaries |
+| `figmog find --type [--page ]` | by_type + nodes | nodes by type, optional page filter |
+| `figmog search [-n N]` | Bm25 + nodes | ranked hits (default 10): score, id, type, name, page, text snippet |
+| `figmog instances ` | nodes + instances_of + components + component_sets | resolve the argument to a component (by node id, global key, or a unique component/component-set name — a set name expands to all its variants), list instance nodes |
+| `figmog components` | components + component_sets + nodes | design-system inventory: component sets with their variant axes, standalone components |
+| `figmog styles [--type ] [--values]` | styles + styled_by (+ nodes) | styles with usage counts; `--values` derives each style's definition from a consumer node (§ below) |
+| `figmog uses ` | styled_by / bound_to + nodes | nodes using a style id or bound to a variable id |
+| `figmog vars [id]` | nodes + variables + variable_collections | variables: authoritative record if imported, else inferred value(s) + binding sites |
+| `figmog import-variables ` | — | upsert variable/collection records from a variables export (see "Variables") |
+| `figmog stats` | nodes + by_type + components + component_sets + styles + variables | node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth — whole-file structural queries the API can't offer at all |
+| `figmog path ` | nodes | ancestor chain root→node: `[{id, name, type}]` |
+| `figmog text [--page ]` | by_type + nodes | every TEXT node's `(id, characters, page_id)`, sorted by id |
+| `figmog where --pointer [--equals ] [--page ]` | nodes | nodes whose raw JSON matches an RFC 6901 `pointer`, optionally filtered by `equals` (parsed as JSON, falling back to a bare string so `--equals VERTICAL` works) |
+| `figmog at --x N --y N` | nodes | nodes whose absolute bounds contain the point, sorted by area ascending (deepest/smallest first) |
+| `figmog bench [file] [--nodes N] [--calls M] [--api-calls K] [--skip-api] [--keep] [--interactive]` | — | self-contained load-test demo, or (`--interactive`) a live REPL — see "Demo: load-testing the server" below — needs no mirror/`--db` |
+
+Node ids accept both `12:34` and `12-34` forms everywhere. Auth is a
+personal access token from `FIGMA_TOKEN`. Since `pull`/`watch` are the only
+commands that touch the network, everything else works fine with no token
+set as long as a store already exists.
+
+## How sync works
+
+`watch` polls the cheap `GET /v1/files/:key/meta` endpoint (Tier 3) every
+interval and only spends a Tier-1 `GET /v1/files/:key` fetch — the
+expensive, rate-limited call — when the file's content-modification
+watermark actually changes. Every fetch, whether from `pull` or `watch`,
+flows through fold's `KeyedStream` upsert-diff: re-syncing a byte-identical
+node is a no-op through the whole pipeline (zero graph churn, zero index
+writes), so a spurious trigger or a repeated `pull` costs one Tier-1 fetch
+and nothing else. Since the November 2025 rate-limit overhaul, file
+endpoints are capped around **10 requests/min on the free (Starter)
+plan**, and there is no delta API — this polling design is what makes
+that budget workable for an agent that wants to treat the file as live.
+The Tier-3 meta poll itself is capped around **50 requests/min on
+Starter**, well above any sane `--interval`.
+
+## Use from agents (MCP)
+
+**figmog is the only Figma MCP an agent needs to connect.** `figmog serve`
+is one process — fjall is single-writer, so a standalone MCP server would
+fight `figmog watch` for the store lock — that owns the mirror, polls for
+changes exactly like `watch`, and (unless `--no-upstream`) also attaches
+Figma's native desktop MCP server as a **cached proxy**: `tools/list`
+merges figmog's 19 local `figmog_*` tools with every tool the desktop
+server advertises, verbatim, so an agent gets one server, one connection,
+and the full native tool surface (`get_design_context`, `get_screenshot`,
+`get_variable_defs`, code-generation tools, …) without figmog reimplementing
+any of it. `figmog serve` also mirrors more than one file in one process —
+see "Multiple files" below.
+
+```console
+$ cargo build -p figmog
+$ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve "https://www.figma.com/design//"
+```
+
+Read-only / offline, once a store already exists (no `FIGMA_TOKEN`
+needed):
+
+```console
+$ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve --db .figmog//db --no-watch
+```
+
+`--interval N` (default 10s) controls the poll cadence, same as `watch`.
+
+**Single-writer constraint:** because fjall allows only one open handle per
+store, `figmog serve` (like `figmog watch`) holds an exclusive lock on its
+`--db` for as long as it runs. A CLI read against the *same* store while
+`serve` is up — `figmog status`, `figmog search`, `figmog call
+figmog_status`, and any other command that opens the store — fails fast
+with a clean `store is locked` error rather than a raw panic; drive the
+running server through its own MCP tool calls instead, or stop `serve`
+first. (`figmog tools` never opens the store, so it works fine even while
+`serve` is running.) The same applies to `serve`/`watch` itself: starting
+a second `figmog serve` or `figmog watch` against a store one of them
+already owns fails with the same clean message rather than a raw panic.
+
+### Multiple files
+
+`figmog serve [FILE]...` takes zero or more Figma file URLs/keys at
+startup — one process can mirror several files. Every local `figmog_*`
+tool gains an optional `file` argument (a URL or bare key, parsed the same
+way as the CLI's own file arguments): pass it to route the call at a
+specific mirror, auto-opening it (spending one Tier-1 pull) the first time
+an agent references it; omit it and the call answers from the *default*
+file — the first `FILE` given at startup, or whichever got mirrored first
+if none were.
+
+```console
+$ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve
+```
+
+Zero files at startup is valid and needs no `FIGMA_TOKEN` up front — the
+server starts empty and mirrors files as an agent references them by URL.
+This is the shape to reach for with `claude mcp add` when you don't want
+to commit to one file ahead of time; passing one or more files at startup
+(as in the single-file examples above) still works exactly as before, and
+the first one becomes the default so every existing single-file tool call
+still needs no `file` argument at all.
+
+Two tools manage the mirror set directly:
+
+- `figmog_open {file}` — mirror a file now (spends one Tier-1 pull);
+ returns its churn and node count. Creates the mirror if it's new, or
+ re-syncs it if already mirrored.
+- `figmog_files` — list every mirrored file: key, name, version, node
+ count, last synced time, and which one (if any) is the default.
+
+**Proxied tools caveat (spec §14, verbatim):** "the desktop server
+operates on the file open in the Figma app; the `file` argument does not
+route proxied tools." A `file` argument sent on a non-`figmog_*` call is
+simply ignored — the desktop server has no concept of "which file", so
+`get_code`/`get_design_context`/etc. always answer for whatever file is
+open in the Figma app, independent of any mirror `figmog serve` manages.
+
+**Accepted divergence:** `.figmog/current` — the file `pull`/`watch`/plain
+`figmog serve ` remember so later CLI commands can drop the file
+argument — is only refreshed by a startup pull that actually *ran*. A
+startup file whose store is already populated (including every
+`--no-watch` invocation, which never pulls at startup at all) leaves
+`.figmog/current` untouched; only a genuine network pull — the initial
+watch-mode pull against an empty store, or a later watch-tick pull —
+writes it.
+
+CLI commands (`pull`, `watch`, `status`, and the rest) are unchanged and
+still address exactly one file via `--db`/`.figmog/current` — multi-file
+addressing is a `serve` capability only (spec §14 non-goal: no CLI
+multi-file addressing, no cross-file queries, no idle-session eviction —
+a session opened stays open for the process's life).
+
+### The cached proxy
+
+Proxying targets **paid Dev/Full seats**: it requires the Figma desktop
+app to be running with its Dev Mode MCP server enabled (streamable HTTP,
+default `http://127.0.0.1:3845/mcp`). At startup figmog probes it; on
+success, every non-`figmog_*` tool call is forwarded there. On failure
+(desktop app not running, no Dev/Full seat, wrong URL), figmog logs one
+stderr line and falls back to local-only tools for the rest of the
+process — no mid-session re-probe, so restart `figmog serve` once the
+desktop server is reachable to attach it.
+
+- `--upstream ` overrides the desktop server's URL.
+- `--no-upstream` disables proxying entirely — figmog serves its 19
+ `figmog_*` tools only, exactly like v2 (plus v4's multi-file surface).
+- **Namespace rule:** `figmog_*` tools are always local; every other tool
+ name is always proxied. If the desktop server ever advertised a tool
+ named `figmog_*`, figmog would drop it and log a warning rather than
+ let it collide — this can't happen with figmog's own registry, but a
+ live desktop server's tool list is outside figmog's control.
+- **Cacheable rule:** a proxied call is served from (and written to) a
+ version-keyed response cache when its tool name starts `get_`/`list_`
+ **and** its arguments carry an explicit node id (`nodeId`, `node_id`,
+ or `id`, as a string) — e.g. `get_code` with a `nodeId` hits the cache
+ on a repeat call for the same node, as long as the mirror's file
+ version hasn't changed since. Selection-based calls (no explicit node
+ id) are always forwarded live. A version bump (from a pull, whether the
+ poll loop's or `figmog_sync`'s) evicts every cache row tagged with the
+ old version.
+- **Only two things spend Figma's API/rate budget:** `figmog_sync` (a
+ forced pull) and any proxied, native-named tool call that reaches the
+ desktop server (a cache hit doesn't). Every `figmog_*` read tool is
+ free, whether or not the proxy is attached.
+- A successful proxied call to a tool that isn't `get_*`/`list_*` (e.g. a
+ code-connect write) may have changed the file, so figmog schedules an
+ immediate meta-poll rather than waiting for the next `--interval` tick
+ (skipped in `--no-watch` mode, which has no poll loop to schedule).
+- `pull --fresh` wipes the store **and** the proxy response cache — a
+ totally clean rebuild.
+
+### CLI parity
+
+Every tool figmog serves — local or proxied — is also reachable from the
+CLI, so you can inspect or drive the exact same dispatch without an MCP
+client:
+
+```console
+$ figmog tools # merged list: name, source, cacheable
+$ figmog call figmog_search --args '{"query": "pricing card"}'
+$ figmog call get_code --args '{"nodeId": "1:2"}' # proxied, cached by version
+```
+
+Both accept `--upstream ` / `--no-upstream`, probed fresh per
+invocation (no persistent connection between CLI calls). There are
+deliberately no bespoke subcommands for upstream tools — Figma's tool
+list churns; `figmog call` is the stable, generic surface.
+
+`figmog tools` and `figmog call` both require a resolved mirror — an
+established `.figmog/current` (from a prior `pull`) or an explicit `--db
+` — even though `figmog tools` itself never reads the store; with
+neither, both exit 1 with `no mirror here — run figmog pull
+first`.
+
+### Core read tools
+
+Each mirrors a CLI read command one-to-one and answers instantly from the
+local store — zero Figma API cost, zero rate-limit exposure. Every tool
+below also takes an optional `file` argument (URL or key) routing the call
+at a specific mirror — see "Multiple files" above.
+
+| tool | input | reads |
+|---|---|---|
+| `figmog_status` | — | file name, version, last modified, node count |
+| `figmog_pages` | — | list CANVAS pages (id, name), in document order |
+| `figmog_tree` | `id`, `depth` | subtree outline rooted at a node; root defaults to the document |
+| `figmog_node` | `id` (required), `children` | full `raw` JSON of one node; `children` inlines a one-level summary |
+| `figmog_find` | `type` (required), `page` | nodes by Figma node type, optionally scoped to one page |
+| `figmog_search` | `query` (required), `limit` | BM25 search over layer names and text content |
+| `figmog_instances` | `target` (required) | instances of a component, resolved by node id, key, or (set) name |
+| `figmog_components` | — | design-system inventory: sets with variant axes, standalone components |
+| `figmog_styles` | `type`, `values` | styles with usage counts; `values` derives each definition from a consumer |
+| `figmog_uses` | `id` (required) | nodes using a style id or bound to a variable id |
+| `figmog_vars` | `id` | variables: authoritative if imported, else inferred from bindings |
+| `figmog_sync` | — | forces one pull and returns the churn — the **only** tool that spends Figma's rate budget |
+
+### Whole-file structural queries
+
+The local mirror's unfair advantage: full-file answers no rate-limited API
+surface could offer, each a read-only scan/join over the same indexes.
+Every one has a matching CLI subcommand, so the CLI/tool surface stays
+one-to-one, and (like the core read tools above) each also takes an
+optional `file` argument.
+
+| tool | CLI equivalent | input | answer |
+|---|---|---|---|
+| `figmog_stats` | `figmog stats` | — | node counts by type/page, component/set/style/variable totals, text-node count, max tree depth |
+| `figmog_path` | `figmog path ` | `id` (required) | ancestor chain root→node as `[{id, name, type}]` |
+| `figmog_text` | `figmog text [--page id]` | `page` | every TEXT node's `(id, characters, page_id)`, sorted by id |
+| `figmog_where` | `figmog where --pointer /p --equals ` | `pointer` (required, RFC 6901 into `raw`), `equals`, `page` | matching `[{id, name, type, page_id, value}]`, sorted by id |
+| `figmog_at` | `figmog at --x N --y N` | `x`, `y` (required) | nodes whose `abs_bounds` contain the point, sorted by area ascending (deepest/smallest first) |
+
+### Relationship to Figma's official MCP server
+
+figmog **replaces** the official desktop MCP server in an agent's config —
+connect figmog instead of it, not alongside it. figmog's `initialize`
+response carries steering `instructions` telling an agent to reach for
+figmog for everything:
+
+> figmog is your Figma server: a local, instant mirror of one Figma file
+> plus a cached proxy to Figma's native capabilities. Call figmog for
+> everything Figma-related. figmog_* tools answer from the local mirror
+> at zero API cost; native-named tools (get_*, …) go to Figma, cached by
+> file version where possible. Pass the Figma file URL as the `file`
+> argument when you have one; figmog mirrors files on first reference.
+
+Every figmog-native tool lives in the `figmog_*` namespace, so it never
+collides by name with a proxied tool; local tools only ever read the
+mirror and only ever write to it via `figmog_sync`, the one local tool
+that spends Figma's Tier-1 rate budget (a forced pull) — every other
+local tool call is instant, free, and backed by the same
+fold-materialized indexes the CLI reads. Proxied tools go through the
+cache described above. `--no-upstream` recovers the older, "second,
+separate server" shape (v2) if that's ever preferable — figmog's 19
+`figmog_*` tools alongside Figma's own, unrelated MCP connection.
+
+## Variables
+
+**Enterprise auto-sync (automatic, zero setup).** Every network `pull`
+additionally calls `GET /v1/files/:key/variables/local` — the Enterprise
+REST endpoint for full-fidelity variable and collection records:
+collections, modes (e.g. light/dark), per-mode values, descriptions,
+scopes. When it succeeds, those records are folded into the same sync and
+kept live: a variable removed upstream is swept on the next pull, exactly
+like a deleted node. On non-Enterprise plans the endpoint 403s (or 404s)
+and the call is **silently skipped** — no error, no flag to set — falling
+back to the two paths below. `--from-file` pulls never call it at all
+(no network involved).
+
+Below that, variables are supported through two complementary fallback
+paths that work on every plan:
+
+**Path 1 — mirrored bindings + inference (always on, zero setup).** Every
+variable-bound property in the file JSON carries a `boundVariables`
+reference, and Figma bakes the resolved concrete value into the same node
+next to it. figmog scans every node for these bindings at every depth and
+inverts them into a `bound_to` index. `figmog vars` aggregates at read
+time: for each variable id, every binding site (node + property path) and
+the observed value(s) baked in there. This covers each variable's
+**default-mode value**; values from a non-default mode appear only where a
+frame explicitly overrides its mode.
+
+**Path 2 — manual import (optional).** `figmog import-variables
+` upserts the same full-fidelity variable and collection
+records the Enterprise auto-sync produces, by hand. It accepts two shapes:
+the Enterprise REST `variables/local` response (the same shape auto-sync
+already ingests, useful for a one-off import outside `pull`), or the JSON
+produced by the free-plan escape hatch below — the Figma Plugin API can
+read local variables on **any** plan, run from Figma's own developer
+console. `figmog vars` prefers an authoritative record (auto-synced or
+imported) over inference whenever one exists. Unlike auto-synced records,
+manually imported ones are **not** swept by a later pull that has no
+Enterprise export of its own (e.g. on a non-Enterprise plan) — they
+persist until re-imported or `pull --fresh`.
+
+```js
+// Figma → Plugins → Development → Open console, then paste:
+(async () => {
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
+ const variables = await figma.variables.getLocalVariablesAsync();
+ const out = { variables: {}, variableCollections: {} };
+ for (const c of collections)
+ out.variableCollections[c.id] = { id: c.id, name: c.name, modes: c.modes, defaultModeId: c.defaultModeId };
+ for (const v of variables)
+ out.variables[v.id] = { id: v.id, name: v.name, resolvedType: v.resolvedType,
+ variableCollectionId: v.variableCollectionId,
+ valuesByMode: v.valuesByMode, description: v.description, scopes: v.scopes };
+ console.log(JSON.stringify(out));
+})();
+// save the logged JSON, then: figmog import-variables vars.json
+```
+
+A third source, for paid Dev/Full seats: `figmog serve`'s cached proxy
+(see "Use from agents (MCP)" above) forwards `get_variable_defs` to
+Figma's desktop server like any other native-named tool, selection-scoped
+and cached by file version like the rest of the proxy. It's still
+selection-scoped rather than whole-collection, so `import-variables`
+remains the way to get an authoritative, whole-collection record into the
+mirror; anyone with a paid seat can pipe a proxied `get_variable_defs`
+call's output into it by hand. Figma's *remote* MCP server (as opposed to
+the local desktop one figmog proxies) caps Starter users at 6 tool calls
+a *month* and isn't something figmog talks to at all.
+
+## Demo: load-testing the server
+
+`figmog bench` makes the value proposition measurable without needing a
+real Figma file or token — one self-contained command:
+
+```console
+$ cargo run --release -p figmog -- bench
+```
+
+It runs four phases against a fresh temp store (cleaned up afterward
+unless `--keep`), all `Instant`-timed:
+
+1. **Corpus** — a deterministic synthetic Figma file (`--nodes`, default
+ 10000): pages of auto-layout frames, TEXT nodes drawn from a fixed
+ 64-word pool (so BM25 has real queries), a "Button" `COMPONENT_SET`
+ with variants and INSTANCE nodes referencing them, fill/text styles,
+ and `boundVariables` bindings. A seeded LCG makes it byte-identical
+ across runs of the same `--nodes` — no wall-clock, no `rand` dependency.
+2. **Cold sync** — `flatten` + `sync` the corpus into the temp store via
+ the library (not a subprocess).
+3. **No-churn re-pull** — `sync` the identical corpus again and assert in
+ code that churn is zero: the engine's headline invariant, timed.
+4. **Serve load** — spawn the real `figmog serve --no-upstream --no-watch`
+ binary and drive it over its actual stdio pipe with `--calls` (default
+ 5000) tool calls in a fixed rotating mix (`figmog_search`,
+ `figmog_node`, `figmog_where`, `figmog_stats`, `figmog_tree`,
+ `figmog_instances`) — every parameter (search words, node ids, the
+ instances target) is *derived* from the corpus's own flattened
+ records, not hardcoded. Sequential over one pipe, matching the
+ server's real single-threaded loop, so the numbers are honest.
+
+Sample output (this machine: Apple M4, 16GB, dev profile — `cargo run -p
+figmog -- bench --nodes 10000 --calls 5000`; dev profile is `opt-level =
+3` in this workspace, so the numbers are respectable even without
+`--release`):
+
+```
+corpus [synthetic] 10000 nodes, 2391449 bytes, 44.7ms
+cold sync 37.3ms flatten + 98.9ms sync, 10005 records (101140 records/s)
+re-pull 7.0ms, churn zero: true
+
+tool calls p50 (ms) p95 (ms) p99 (ms) max (ms)
+figmog_search 834 0.399 0.531 0.911 3.654
+figmog_node 834 0.043 0.058 0.152 8.374
+figmog_where 833 15.302 17.161 25.223 48.124
+figmog_stats 833 24.089 27.427 47.298 160.400
+figmog_tree 833 3.971 5.014 9.264 20.122
+figmog_instances 833 0.749 0.903 1.647 4.295
+
+figmog served 5000 queries in 38.4s (130 req/s). Figma's Tier-1 API budget on a free plan: ~10 file requests per MINUTE.
+```
+
+(`figmog_node` — an indexed point lookup — is the fastest tool by a wide
+margin; `figmog_where`/`figmog_stats` scan every node and are
+correspondingly slower, but still complete a 5000-call load test in
+under 40 seconds against a 10000-node file. All of it is local: zero
+Figma API calls, zero rate-limit exposure.)
+
+**Against a real file** (`figmog bench `, needs
+`FIGMA_TOKEN`): the corpus becomes the real file — fetched once (exactly
+one Tier-1 call, plus the same opportunistic Enterprise `variables_local`
+call `pull` makes), reused in memory for the re-pull phase (no second
+fetch) — and the load test's query mix derives its parameters from
+whatever's actually in that file (falling back gracefully, e.g. dropping
+`figmog_instances` from the mix with a stderr note if the file has no
+components). Unless `--skip-api`, a fifth phase follows the serve load:
+`--api-calls` (default 5) sequential `GET /v1/files/:key/nodes?ids=`
+calls — Figma's native equivalent of `figmog_node` — timed the same way,
+plus one `GET /meta` call for reference, so the report can show
+`figmog_node` p50 next to the real API's p50 side by side, with a
+speedup factor and the budget math (how long the same `--calls` load
+test would take at Figma's ~10 Tier-1 requests/minute). **This spends
+real rate-limit budget**: 1 file fetch + 1 opportunistic
+`variables_local` + `K` `/nodes` calls + 1 `/meta` call — the report
+states every call it made; a 429 mid-phase is recorded (with its
+`Retry-After`) and ends the phase gracefully rather than failing the
+whole bench.
+
+### Interactive mode (`--interactive`)
+
+`figmog bench [file] --interactive` runs the same setup (corpus/real file
+→ cold sync → no-churn re-pull → serve child spawn) and then, instead of
+the automated load/API phases, drops into a REPL so requests are visible
+as they fire — one aligned line per call: sequence number, tool, arg
+digest, latency, and a result digest. Colors (green under 10ms, yellow
+under 100ms, red above; errors always red) are raw ANSI, emitted only
+when stdout is a real terminal — piped/non-TTY output (CI, this README's
+transcripts) is always plain text.
+
+| command | does |
+| --- | --- |
+| `search ` | BM25 search over layer names/text |
+| `node [children]` | full node JSON |
+| `tree [id] [depth]` | subtree outline |
+| `find [page]` | nodes by Figma node type |
+| `where [value]` | nodes matching an RFC 6901 pointer (`value` parsed as JSON, falling back to a bare string) |
+| `stats` | node counts, totals, max depth |
+| `path ` | ancestor chain to a node |
+| `text [page]` | every TEXT node's characters |
+| `at ` | nodes containing a point |
+| `instances ` | instances of a component |
+| `components` | design-system inventory |
+| `styles [type]` | styles with usage counts |
+| `uses ` | nodes using a style/variable id |
+| `vars [id]` | variables |
+| `pages` | list pages |
+| `status` | file name/version/node count |
+| `run ` | fire N requests of the derived mixed workload, streaming each line live, then a burst percentile table |
+| `report` | cumulative per-tool percentiles for everything fired this session |
+| `api node ` / `api meta` | real-file mode only — one live Figma API call (`/nodes` or `/meta`), timed the same way and labeled with the API cost it spent; a 429 prints its `Retry-After` in red and the REPL keeps going |
+| `call |