| name | map |
| description | Per-site navigation memory for tandem's shared browser. Profiles that describe a site's skeleton (routes), its durable locators, navigation recipes, and gotchas, so you navigate KNOWING instead of re-deriving the DOM each time. Use it when you start operating a site (check whether a profile already exists), when you finish a recon worth saving, or when a saved locator fails (re-record). ASSISTED recon: you draft, the human confirms, it's saved. |
tandem:map โ per-site navigation memory
Persists how a specific site is navigated in a per-host profile. Avoids re-deriving the
structure (huge snapshots, ephemeral refs) every session. Complements the
"Real navigation โ patterns" section of the tandem skill: that is CROSS-site technique (infinite
scroll, cookie bannersโฆ); this is knowledge OF-a-site (the routes of THAT site).
Full design and rationale: tandem/docs/01-navigation-memory.md.
Store (engine: scripts/map.sh)
Profiles in ~/.claude/tandem/sites/<host>.md (OUTSIDE git; override TANDEM_DATA_DIR).
The SOURCE OF TRUTH are the .md files; the index is DERIVED from their frontmatter, not kept separately.
map.sh list ............ table of profiles (host ยท updated ยท file)
map.sh show <host> ..... dumps the profile (accepts a full URL: normalizes to host)
map.sh path <host> ..... the file path (whether it exists or not) โ for reading/writing
map.sh index ........... derived JSON index (to stdout + caches sites/index.json)
Invoke it by its path within the plugin (scripts/map.sh); there's no bin on PATH for map.
When to act (reflexes)
- When you start operating a site: if a profile already exists, it usually reaches you on its
own โ a
PostToolUse hook on browser_navigate injects the host's profile the first time you
navigate to it in the session (scripts/hook-inject-profile.mjs). If it didn't arrive (or you want
to re-read it): map.sh show <host>. If there's no profile and the work isn't trivial โ offer the
human to do a recon and save it.
- Before trusting a route's locators (cheap re-check): validate with
fingerprint.mjs check <host> <route> (structural signal, small JSON), NOT with a
targeted browser_snapshot (it weighs ~18ร the whole profile). match โ trust that route's sel:
values WITHOUT a snapshot (see "Frugal flow"). drift โ point 4. No fingerprint yet โ a one-off
targeted snapshot, and capture the fingerprint for next time.
- After a recon worth keeping: draft the profile, show it to the human,
and only with their OK write it.
- If a locator fails (drift): do NOT try to repair it. Re-derive live, update the
profile line with the new anchor and today's date, and tell the human about the change.
Rule: a wrong cached click is worse than a slow click.
How recon is done (ASSISTED โ never auto-save)
- Explore with the
browser_* tools (targeted snapshot, browser_evaluate for data,
navigating routes). Don't dump raw DOM into the profile: abstract it.
- Draft using the structure below. Mark each line:
[verified YYYY-MM-DD] (you lived it now) or [hypothesis] (inferred, untested).
- Show it to the human. They confirm/correct. Until then it isn't saved.
- Write to
$(map.sh path <host>). Set created/updated in the frontmatter.
Locator discipline (what keeps this from lying)
- Multi-anchor, stable properties. Prefer a stable
id if one exists; if not, role+name
from the accessibility tree. Never position/index nor eNN refs (they're ephemeral).
- Save the primary + 1-2 corroborants (neighboring text, shortcut, container). On re-check,
accept the element if the set matches, not just the primary. (Idea stolen from Similo:
several weak weighted signals beat a single strong anchor.)
- Lean and abstract. Skeleton + locators + recipes. If a page is huge, the profile
stores HOW to get there and WHAT to look for, not the dump.
- Executable
sel: (opt-in). Besides the readable primary, a locator can carry a sel:
= a Playwright selector the agent passes as target to act WITHOUT a snapshot. Generate the
syntax with scripts/selector.mjs (NOT by hand โ the quote/regex escaping is easy to mistype):
selector.mjs <role> <name> [--regex] [--anchor]. Two classes:
- instance (one element): direct
sel:, e.g. role=button[name="New ticket"].
For long/fragile names use --regex (substring) instead of the exact literal.
- template (several: rows, list items):
sel: with a slot the recipe fills in
use, e.g. role=row[name=/^{id}/]. A common prefix (^TCK-) matches N rows โ the action
is strict and throws; that's why it's parameterized to the concrete id.
- If an element has no unique role/name, do NOT give it a
sel: (forcing positional CSS lies).
- The
sel: is ONLY the selector, no inline comments (recipe.mjs/selector.mjs would
include them in the value). Notes go in primary:/corroborates:.
- Not every "table" exposes ARIA. Many SPAs render a
<table> that the tree flattens to
generic (no role=row/cell) and duplicate the list for responsive. Verify LIVE before
anchoring by role=row; if there's none, anchor by stable class + text= + >> visible=true. The
selector.mjs helper (v1) only covers role=โฆ[name] โ these cases are written by hand for now.
Drift by signal (fingerprint, T015) โ staleness by evidence, not by date
Instead of trusting how old the [verified] tag is, compare the structural SKELETON
of the page against the one you saved. If it changed โ that route's locators are suspect,
time to re-check/re-record (the date doesn't tell you that: a page untouched for 6 months is still valid;
one that changed yesterday is stale even if the tag is from yesterday).
Engine: scripts/fingerprint.mjs {capture|check} <host> <route-key> (JSON signals via stdin).
Sidecar sites/<host>.fingerprints.json (machine, separate from the readable profile). check
returns {status: new|match|drift, added[], removed[]} and rc 1 if there's drift; the diff tells you
EXACTLY which locator changed.
Canonical evaluate โ captures only STRUCTURE (roles, landmarks, buttons, inputs, headings,
nav-links), not table data, and normalizes digits to # so counts/dates/ids don't count
as drift. Do NOT type it by hand: the SINGLE SOURCE is scripts/page-signals.mjs (versioned and
with tests). Get the exact JS and pass it to browser_evaluate verbatim:
node scripts/page-signals.mjs print # emits the self-contained () => [...] blob
Why a file and not the snippet here: changing the extraction invalidates ALL the stored
fingerprints (false drift everywhere). With the single source, the algorithm can't drift without its
tests catching it (tests/page-signals.test.mjs); the blob is serialized from those same functions.
Flow: browser_evaluate with the output of page-signals.mjs print โ pipe the array to
fingerprint.mjs capture|check <host> <route>.
- capture: after verifying a route's locators in recon, save its fingerprint.
- check: on revisit, compare.
drift โ re-record-on-drift (re-derive, update the profile) and
recapture the fingerprint. match โ trust the profile. new โ not captured yet.
Gotcha [verified 2026-06-22]: BEFORE capture/check confirm that location.pathname is the
expected route. If the session expired and you bounced to /login (or another wall), do NOT capture โ
you'd save the login skeleton under the wrong route-key. It's an auth wall โ the human clears it, then you repeat.
Frugal flow (act by sel: without re-snapshotting)
With a profile loaded, do NOT snapshot by default to act: the tree snapshot weighs ~18ร the
profile entire (measured: 105 KB vs 5.6 KB). The snapshot goes from default mode to honest FALLBACK.
- Gate by fingerprint, not by faith. Before trusting a route's
sel: values,
fingerprint.mjs check <host> <route>. match โ the sel: values are good, act by them. drift/new
โ re-derive with a targeted snapshot (and capture/recapture the fingerprint).
- Act by
sel:. browser_click/type/evaluate with target=<sel>. Zero snapshot.
- SPA: wait before acting. On deferred-render routes (seen in a real SPA: "first
empty snapshot" before hydrating),
browser_wait_for on the sel before the click; otherwise the element
may not exist yet and the savings go into retries.
- If the
sel: fails (doesn't resolve, or throws on ambiguity) โ treat it as drift: targeted
snapshot, re-derive, update the sel: (assisted re-record, reflex 4), recapture the
fingerprint. A wrong cached sel: is worse than a slow snapshot.
Fallback to snapshot = no profile, drift, or reading new unmapped data.
Executable recipes (procedural memory)
A recipe is a NAMED and PARAMETERIZED sequence of steps over the profile's sel: values. Flat-YAML
format under ## Recipes, compiled by scripts/recipe.mjs:
open-ticket-by-id(id):
- type: ticket-search <- {id}
- click: ticket-row <- {id}
- wait-url: /tickets/
- return: url
- Header
name(params):. Each step = action: locator [<- {param}|"literal"]. The {param}
fills the sel: TEMPLATE (the slot must be named the same as the param). v1 actions:
navigate, type, click, wait-url, return/extract. Do NOT type the Playwright syntax by
hand: recipe.mjs compiles it. When saving a recipe in recon: recipe.mjs validate <host>.
Running it โ two modes (recipe.mjs COMPILES, the agent EXECUTES):
--step (default, observable): recipe.mjs compile <host> <recipe> <args> --step โ steps
{action,target,value} โ run them with browser_type/click/wait_for. The human sees each step.
Precede it with the fingerprint check gate (same as for a standalone sel:).
--fast (frugal, 1 call): recipe.mjs compile โฆ --fast โ a single Playwright function. Mandatory
dry-run: show the code to the human; with their OK, run it with browser_run_code_unsafe. That
tool is RCE-equivalent โ only recipes from the co-curated store, never model or page text.
- PII: a recipe that
extracts a sensitive datum (IBAN/national ID) โ that datum goes to an ACTION,
not to the context/log. The dry-run makes it visible before running.
Profile structure (<host>.md)
---
site: <host>
created: YYYY-MM-DD
updated: YYYY-MM-DD
auth: { wall: "<which wall>", cleared_by: human|claude|none }
---
# <host> โ navigation profile
## Routes (skeleton) โ URL pattern โ what it's for
## Locators (multi-anchor) โ name: sel (optional, executable) + primary + corroborates + | verified DATE
## Recipes โ executable (flat-YAML): name(params) + steps `action: locator <- {param}`
## Gotchas โ induced rules, each one [verified DATE] or [hypothesis]
Format example: see docs/01-navigation-memory.md.
No-goals (v1)
- No automatic action: the hook injects the profile into the CONTEXT, but navigating/acting is always
your decision (it never auto-pilots from the profile). AUTHORSHIP (recon/writing) is assisted too.
- No self-healing that rewrites the profile on its own: re-record + human re-confirmation.
- No rigid/rich schema: lean wins, scales by addition.