agentleFS
ProjectAJ14ProjectAJ14/eklavya/.claude/skills/eklavya-dashboard/SKILL.md

eklavya-dashboard

Skill29 starsChanged 26 days agoMITView on GitHub

How Eklavya's dashboard (`eklavya dashboard`) is built and how to change it — the six workflows (Learning, Memory, Artifacts, Feedback, Cost, Settings) and their registry, the one JSON payload plus the project inventory, the live event stream and the per-cursor memo behind it, the URL-driven hash router and its legacy redirects, how a redraw keeps the reader's place, the hand-rolled SVG charts, the table/pagination helpers, and the checks a change has to pass. Use whenever adding, editing or debugging anything in mcp/src/dashboard.ts or mcp/src/assets/dashboard.html, or when a request mentions the dashboard's sections, charts, filters, drill-downs or routes.

The file

We split this skill into the rules it gives an agent

11 rules. About 0 are common, 4 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    When iterating: npm run build in mcp/ (tsc + copy-assets), then node dist/cli.js dashboard --port 41799 --no-open.

    line 40
  2. Distinctive:

    Never discover projects from a capped array or a page of the timeline.

    line 91
  3. Very specific:

    /api/state is append-only: Add keys; never rename or remove one.

    line 162
  4. Distinctive:

    All charts are hand-rolled inline SVG.

    line 354
  5. Very specific:

    Search text and sort order stay in T: they are typing, not destinations.

    line 395
  6. Very specific:

    Do not add a route, a query string or a render path that writes acknowledged_at.

    line 581
  7. Very specific:

    The one frame: the viewer: Every page opens at #/artifacts/view/<id>, as a tab: an <iframe> of /artifacts/<id>?embed with sandbox lacking …

    line 635
  8. Very specific:

    Every stored string goes through esc() on its way into a template, including inside data-* attributes, and dashboard.test.ts has a case …

    line 717
  9. Distinctive:

    Keep the pre-migration rows.

    line 734
  10. Distinctive:

    Every section and both detail levels, at 1280, 900 and 560, in both grounds.

    line 761
  11. Very specific:

    No console errors, no outbound request, and scrollWidth <= clientWidth at 560 and 390 — dashboard-browser.test.ts checks all three on every …

    line 763

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Working on the dashboard

eklavya dashboard serves the learning and memory history as a local web page. It is the long view — /eklavya:progress gets twenty lines and answers what now, this answers am I getting better and what did that session actually teach me. It is six workflows in one shell, Learning, Memory, Artifacts, Feedback, Cost and Settings, each with its own Dashboard and sidebar.

Two client/server files, shared design tokens and mascot assets, plus one vendored library. The exception is the tips library: Driver.js's hints module, pinned exactly ("driver.js": "1.9.0", a devDependency), copied from node_modules into dist/assets/vendor/ by copy-assets.mjs (the build fails without it) and served from this origin. A second library needs the same case made, and never comes from a CDN.

FileWhat it is
mcp/src/dashboard.tsdashboardState(db) — the whole payload — projectInventory(db), the one list of projects both workflows use, memoryPage, memoryEntry and memorySessionPage for the paged memory resources, localTokens (the shared tokens minus their remote font import), SETTINGS / CLI_ONLY (the settings registry), settingsState and updateSetting, and startDashboard, a loopback http.createServer with explicit read routes: /api/health (app, version, pid and database, read by dashboard-daemon.ts), /api/state, /api/cursor (changeCursor: the cursor on its own, which the page no longer polls), /api/events (createLive: the cursor as a text/event-stream, GET only), /api/projects, /api/memory, /api/memory/entry, /api/memory/sessions, /api/settings, /api/attempts/correction, /api/feedback and /api/feedback/list (the Feedback workflow's item and its acknowledged list), /tokens.css, /brand/mascot/mascot.js, /brand/mascot/mascot.css and /mascot.html (shared Archer Cloak component and studio), /daily-card.js (the daily card engine, in the same allowlist as the mascot files), /vendor/driver-hints.js and /vendor/driver-hints.css (the tips library, VENDOR, cached an hour), /manifest.webmanifest and the three PNG icons it and the page name (MANIFEST, APP_ICONS: what makes the page installable as an app), /artifacts/<folder>/<file>, / — and the writes in WRITES (POST /api/settings, POST /api/attempts/retry, POST /api/feedback/acknowledge, /api/feedback/delete and /api/feedback/opened, POST /api/clean-slate), all through acceptWrite. cleanSlate is the one handler that returns a new db: startDashboard holds db in a let, swaps it and recreates the live watcher, so every route reads the handle at request time. Any other method is a 405, and every response carries SECURITY_HEADERS (a same-origin CSP with frame-ancestors 'none', nosniff, X-Frame-Options: DENY, no-referrer; an artifact overrides the framing pair to 'self' / SAMEORIGIN for the viewer) — a new route goes through send() or it ships without them. Memory search escapes %, _ and \ and uses LIKE … ESCAPE '\', so the box matches them literally. Reads other than /api/health, /api/cursor, /api/events, one feedback item and a correction are answered from a per-database memo (memoFor, stateBody, inventoryOf, pageBody: see The stream, the watcher and the memo). send() answers no-store unless the caller says otherwise: CACHE_HOUR (private, max-age=3600) for /tokens.css, the mascot files and thumbnails, max-age=3600 for the tips library, and / goes out no-cache with an ETag (the write token is in the page, so a restart changes the tag). DEFAULT_PORT lives in paths.ts (re-exported here) so the SessionStart hook can probe the port without importing this module.
mcp/src/assets/dashboard.htmlThe entire client: styles, markup shell, workflow registry, router, views, charts. One file, no framework, no build step.
mcp/test/dashboard.test.tsThe payload's contract, the inventory's rules, and /api/state's key set.
mcp/test/dashboard-browser.test.tsThe page in a real Chromium: every legacy redirect, the workflow control, collapse persistence, the drawer, picker bounds, overflow, console errors and outbound requests.
mcp/test/dashboard-events.test.ts, dashboard-memo.test.ts, dashboard-queries.test.tsThe stream and its watcher (the intervals are startDashboard's opts.live, so a case runs in milliseconds), the memo, its expiry and the headers (read off the SQL the database prepares, not a counter in the code), and every rewritten read held beside the statement it replaced.
mcp/test/daily-card.test.ts, dashboard-daily-browser.test.tsThe daily card engine (gate, rank, decide, remember and the streak lines, loaded with require and run through vm the way a page loads it) and the card in Chromium: once a day, the places it never opens, close and focus, the Settings switches, two tabs, narrow windows.
mcp/test/dashboard-inpage-browser.test.ts, dashboard-fills-browser.test.ts, dashboard-render-browser.test.ts, dashboard-live-browser.test.tsThe page in Chromium, one rule per file: an adjustment keeps the reader's place, a revisit makes no request and paints no loader, the kept lists and the sidebar equal a fresh build, and a real write from a second database connection reaches an open page without moving the reader. probeTransition in dashboard-browser-helpers.ts records each transition (loader, collapse, requests, scroll).
mcp/scripts/dashboard-perf.mjsSeeds a temporary learner at small, medium or large and times the builders, the payload and the routes; --live also times a write reaching an open page in headless Chromium. Documented in CONTRIBUTING.md; run it before and after a change to builders, queries, caps or caching.
mcp/test/dashboard-fixture.tsOne synthetic learner with every project shape — memory-only, logged-but-unanswered, answered-only, mixed, worktrees (one deleted), same-named repos, a gone checkout, no-repository and legacy rows. Both suites and the screenshots use it.
web/src/content/docs/docs/dashboard.mdxThe manual page. It is a test of this code, not prose about it.

web/public/tokens.css is copied into dist/assets/ by mcp/scripts/copy-assets.mjs and served at /tokens.css. The dashboard and the site therefore share one palette by construction; do not fork it. The site's copy imports Google Fonts; localTokens strips that import on the way out, and the page itself loads no font — the dashboard makes no request to any other host, and the browser suite fails if it does. The font stacks fall through to system faces.

When iterating: npm run build in mcp/ (tsc + copy-assets), then node dist/cli.js dashboard --port 41799 --no-open. Use --no-open — the command opens the reader's browser by default, and a restart loop without it spawns a tab every time. Use --port too: without it the command reuses or starts the background dashboard on 41729 (dashboard-daemon.ts) instead of serving your build in the foreground. Editing only the HTML? cp src/assets/dashboard.html dist/assets/ and reload — no rebuild needed.

The one rule about data

One payload of mostly flat rows. The page derives the views.

/api/state ships the newest ATTEMPT_LIMIT attempts and the newest LOGGED_LIMIT logged context lines (800 each), one row per day for 365 days, the whole concept catalogue with its SM-2 state and graph edges, and per-project level standing. The sessions list, the streak, the heatmap, the forecast and one concept's question history are five readings of the same rows.

So: do not add an endpoint or a SQL aggregate per card. A query shaped like a card is a query that has to change when the card moves. Aggregate in SQL only for what the page cannot honestly derive — all-time totals, which must stay right even though attempts and logged are capped at ATTEMPT_LIMIT and LOGGED_LIMIT rows. A cap shortens a list and never a number: the totals, daily, the catalogue (its counts and last context) and the inventory's per-project counts and totals are aggregated over every row, and a page states its numbers from them (scopeTotals: the Learning Dashboard's answers, accuracy and "recorded from your work, assessed" line, the Review page's skipped count), never from attemptsInScope() or loggedInScope(), which are only the newest rows once a list is cut. The two caps are the payload's size budget, 1.5 MB at a year of daily use (800 rows of each kind is 1.15 to 1.44 MB, by the length of the repository path every row carries; scripts/dashboard-perf.mjs medium prints it), so raising either needs that budget measured again.

Two exceptions, and both are resources, not cards.

The first is /api/projects: the project inventory. projects in /api/state is built from attempts and so cannot see a project that logged concepts but was never asked a question, or one that only captured memory; the page's selector and both Projects pages read the inventory instead. It establishes a project from any row that names one (attempts, logged concepts through their gate, evidence — processed or not — entries, receipts, project_levels), folds identities with projectKey, and ships aliases (every raw spelling → one id) and sessions (no-repository sessions proven by their own rows). Each project's learning also carries concept_slugs (every concept any answer or logged line of any origin names for it, from every row, sorted, attributed as pid() does), sessions and logged_assessed_concepts (of its logged_concepts, the ones also asked), so a project's scope and its counts never depend on how many rows the payload holds. The inventory's totals (sessions, logged_concepts, logged_assessed_concepts) are what no sum of the cards gives: a session that worked in two checkouts, or a concept two projects logged, is one. The page reads them for the all-projects scope (sessionTotal, scopeTotals) and a project's own card for one project. The page's pid(raw, sid) is the only place a row is given a project — call it, never compare repo strings. Never discover projects from a capped array or a page of the timeline.

The second is the memory corpus. An observation carries a narrative and the tool output it was distilled from, so a year of them is megabytes and shipping it on every load is what PRD DASH-02 forbids. /api/memory is one paged, filtered endpoint over memory_entries (project, session, type, tag, q, since, until, page, per) and /api/memory/entry?id= is one observation with its tags, its raw evidence, its candidates and its receipts. Both are resources — a filter added to them is a parameter, never a second endpoint. Everything else about memory (the counts, the facets, the receipts ledger, the health block) still rides in /api/state.

/api/memory/sessions is the same kind of resource over sessions that captured or remembered anything, with a real total, because memory_sessions in the state payload stops at MEMORY_SESSION_LIMIT. With ?session=<id> it also returns learning: { logged, attempts, attempts_total }, everything that session logged and was asked however old (sessionLearning, in the payload's row shapes): how a session the caps cut, or one that captured no memory, is opened whole.

/api/events is a resource too, the cursor as a stream, and not a card: it says that something changed and never what.

The asynchronous views (the timeline, an entry, the Memory Dashboard's two lists, Memory Sessions, the memory half of a session, a session looked up because the caps cut it, Feedback's item and history, and Settings) return slot(id, url, draw, opts) where the target element goes; fill(id, url, draw) is what it queues, on AFTER exactly as chart() does. slot is the one place that decides what a fetched region shows:

  • A hit draws at once. Answers are cached by URL (RESP: parsed bodies, never markup; RESP_MAX is 50, least recently used out first), each under the S.cursor it was read under. A hit draws inside render(): no request, no loader. The URL carries the project, so a project switch leaves the cache alone. invalidateData() clears it (a save, and adoptState when new data arrives), a write that changed one answer forgets just that prefix (respForget), and a read that was out when the cache was dropped is not stored when it lands (respAt).
  • A miss reserves its room. The target holds a box as tall as that region last drew (SLOT_H), else an estimate (slotMin: a list's rows the page already knows of, at most one page of them, times what a row takes, from SLOT_ROWS; the rest are SLOT_MIN, measured at a year of use), with the mascot loader in it after its own 150ms delay. The footer moves by however far the estimate misses, once, and a short history is no longer given room for a year of it.
  • A region that already shows an answer keeps it. A pager, a filter or a refetch of the same region leaves the old content under aria-busy and data-kept: out of the pointer's reach at once (and the keyboard's, in onActivate), dimmed only if the answer takes longer than 150ms (a step: transition: opacity 0s linear 150ms, and fill computes the target's style before it sets aria-busy, so an element the same task inserted waits its 150ms too), so one that comes from the server's memo in a few milliseconds never flashes. A failed read shows its error and leaves nothing dimmed.

Each render bumps GEN, and a response from an older render is dropped (and cached for later), so a late fetch can never paint one workflow's content over the other's. A draw may queue its own charts and slots; slot runs them. Otherwise draw is a pure function of the response, and must not change it: a cached body is drawn again.

adoptState(s, inv) is the one place S and INV are assigned after boot (boot and liveAdopt call it, and a test reads the source for that). It recomputes the scope and drops what was derived from the old data and what was read under it. The lists the views read (sessions(), conceptsInScope(), seenConcepts(), the attempts and logged rows in scope, dailyInScope(), streaks(), the sidebar's counts, the heatmap's bucketed days) are built once per S, INV, PROJECT and local day (derived()), then frozen, so a caller that sorts or pushes onto one throws: copy first, and never write to a row. The Learning Dashboard draws in about 15 ms on a visit after the first at a year of use and at a large history, and in about 39 ms the first time a page draws it (the first layout and the first run of its code are in that), from about 60 and 90 before this work (headless Chromium in the sandbox the numbers were taken in; reported, not asserted in CI). The first draw at a year of use is over the issue's 30 ms target.

/api/state is append-only. Add keys; never rename or remove one.

Live, and never moves the reader. This replaces the old rule, announced, never silent (a button offered a reload because a redraw would have moved the table the reader was halfway down). The open dashboard shows new activity by itself. A redraw on new data may change numbers, rows and badges in place. It may not change the scroll, the focus, the caret, an open fold, an open choice list, a date being typed, a typed value, a text selection, an open tip bubble or a framed page. Markup the new data did not change replaces nothing, wherever the view has no regions. A banner, a button or a toast that asks for a refresh is a defect, and so is a poll: dashboard-live-browser.test.ts holds the page to this with real writes.

The stream, the watcher and the memo follow changeCursor. The cursor is the change_version counter that triggers move (migration 021), a hash of the effective config (the user file's and each answered project's) and a stat of the artifact files. A new table the page shows needs its triggers in a new migration, or work landing in it never moves the cursor and an open page never hears of it; a column that changes on every turn without changing the page needs a WHEN like gates'. /api/state carries the cursor it was built under as cursor, which is what the page compares each event with.

  • The stream. GET /api/events (createLive) sends event: cursor with the current cursor on connect and again each time it moves, with a : keep-alive comment every EVENTS_KEEPALIVE_MS (25 s). It carries nothing else: a thing to push is a field of /api/state. It sits behind the same handler as every read (the loopback check, SECURITY_HEADERS, no-store), refuses the 33rd open stream with a 503 (EVENTS_MAX_STREAMS) and cuts a stream that will not take a write instead of buffering for it. /api/cursor stays for older pages and anything that polls.
  • The watcher runs only while a stream is open, and hooks, the SessionStart path and /api/health never wait on it. The floor reads changeCursor every EVENTS_FLOOR_MS (1 s, 0.5 to 3 ms a read); it keeps the stream right where fs.watch is silent (a network mount, an editor that replaces a file). The early trigger is fs.watch on the database's -wal (through its directory, by name), the user config file, each project's config file and each artifact folder, debounced by EVENTS_DEBOUNCE_MS (150 ms) into one check. Each folder is watched by itself, never through one recursive watch: on Linux that holds a watch per inode, so the first atomic rewrite of a file (every config write, most editors' saves) leaves the new file unwatched. Every watch is optional and a failed one is dropped. A WRITES route calls live.bump() after it writes, so the page that wrote and every other open page redraw without waiting for a check. On a change the memo is rebuilt first (warmMemo), then every stream is written, so the read the event provokes is a repeat. The writers are other processes (hooks, the MCP server, the CLI), which is why the server reads the database and cannot be told.
  • The memo is one per database handle, in a WeakMap (memoFor), valid for the cursor and reviewFailed as they were read before any build stored in it. It holds /api/state as the bytes it is sent as, the project inventory (kept across a config or artifact change, which it does not read) and an LRU of serialised responses by full URL (pageBody, PAGE_CACHE_ENTRIES, none over 1 MB). The cursor does not cover the clock (scores decay at read time) or the filesystem (a checkout's .git, the artifact files), so the state, the inventory and the Settings pages also expire after MEMO_TTL_MS (60 s). reviewFailed is asked beside the cursor because feedback_reviewed has no change triggers on purpose and the state says "could not review" from it. Settings adds a stamp of the config files and of the inventory's build time, and a settings write drops its pages (forgetSettings). Never memoized: /api/cursor, /api/health, one feedback item (a POST acknowledges it) and a correction (graded live). The first build is made when the server starts listening (warmMemo, unref'd, swallowed on failure). Do not add a per-request build to a read.
  • The page holds one EventSource while its tab is visible. On a cursor that is not S.cursor it reads /api/state and /api/projects, swaps them in (adoptState), writes what boot writes outside the view (paintChrome: the footer's database path and cap line, the dial line, the project picker) and draws the screen it is on again through syncFromUrl with LIVE_PASS set. Events that arrive while a read is out are one more read (LIVE.busy, LIVE.more). A hidden tab closes the stream and makes no request, and the reconnect's first event is the current cursor, so a tab left open all afternoon catches up in one read. A server that goes away is the browser's to retry, and the page keeps what it has with no banner; a stream the server refuses (a 503) is not retried by the browser, so the page opens it again after 5 s, doubling to 60 s. A write the page made itself redraws from its own answer, and the event that follows replaces nothing on screen (it is one more read of /api/state, /api/projects and, on Settings, /api/settings, which finds the markup unchanged).
KeyShape
configquiz_enabled, quiz_enforced, focus, focus_topic, cadence, difficulty, plus the thresholds the Projects page reports against. The two quiz_* booleans replaced a single mode string — the dial line renders enforced only when set, and questions off when quiz_enabled is false
totalsall-time answers/passed/missed/skipped, mastered, due, touched, catalogue, sessions, active days
daily{day, repo, passed, missed, skipped}, one row per day per project, 365 days
projectsanswers, accuracy, concepts, first/last active, plus levelStanding — level, next, counts, needed, unmet
domainscatalogue / touched / mastered / learning / unseen
conceptsthe whole catalogue: tags (a list, from concepts.tags), description, seen, decayed score and stored_score, ease, interval, reps, due, overdue_days, last context, prereqs / unlocks / related as slugs
attemptsnewest ATTEMPT_LIMIT (800): question, options JSON, answer, feedback, grade, tier, outcome, format, repo, level, session id
attempts_shown, attempts_totalthe rows that travelled and every answer there is; the footer prints a line when shown is less than total
loggednewest LOGGED_LIMIT (800) session_concepts rows: slug, context, origin, ts, session id, repo
logged_shown, logged_totalthe same disclosure for the context lines; logged_total is the count over every row
cursorthe changeCursor the build was made under, which the page compares with each /api/events event
memorycounts, not rows: captured / processed / indexed / reused / exposed / assessed, entries live vs superseded vs deleted, candidate statuses, and the type / tag / project facets the filters are built from
reusereceiptTotals (confirmed rows only), the savingsFrom verdict and savingsLine, the estimator's name, counts by delivery, and the newest RECEIPT_LIMIT receipts with their index/detail split
healthcapture heartbeat and mode, queueDepth, stalled jobs grouped by error_class, the spool's drop count, and whether a provider is configured
memory_sessionsone row per session that captured evidence: events, entries, candidates, first/last — what lets the Sessions view line the two halves up
feedback{ enabled, memory, observer, pending: { id } | null, notify, acknowledged, failed } from feedbackSummary: switches, whether an item waits (its id, never its text), whether to show the badge (notify: waiting, feedback on and memory on), the acknowledged count and whether the last session looked at could not be reviewed. The prompt text is only ever served by /api/feedback to the page that shows it
artifactslistArtifacts() from artifacts.ts: every page under ~/.eklavya/artifacts/, newest first — id (<folder>/<file>), title, description, project, kind (explainer or artifact), concept, attempt (the eklavya:attempt meta, or null), created, bytes, plus correction (open, done or null, one query for all rows). Read from the files' heads on each load; there is no table

Two things that have bitten this file already:

  • Timestamps come in two shapes. attempts.ts is SQLite's 2026-09-05 21:04:00 — UTC with no marker, which Date.parse reads as local. next_review and last_seen are JS ISO strings that already carry a Z. Both ends have a parser that handles both (parseTs in dashboard.ts, parse() in the page). Appending Z unconditionally gives ...ZZ → NaN, and every overdue count silently reads zero. Never write a third parser.
  • A day is the learner's local day. The server groups with date(a.ts, 'localtime') (the dashboard runs on the learner's machine), and the page reads "today" only through todayKey() (localKey(new Date())). Stepping between keys (fromKey, +DAY_MS) stays in UTC. A UTC "today" puts an answer at 01:00 in India on yesterday and breaks a streak.
  • Scores are decayed at read time, exactly as get_learner_profile does it (decayedScore, isKnown, isDue from srs.ts). Two surfaces disagreeing about one score is worse than either being wrong. Import from srs.ts; never re-implement the arithmetic in the page.

Workflows, routes and adding a view

WORKFLOWS is the registry: per workflow, its name, its sidebar groups ({ id, label, items }), and pages — { label, view, query } for a page with a sidebar entry, { parent, view } for a detail page, which keeps its parent highlighted. The sidebar, the picker, the resolver and render() all read it, so they cannot disagree. ICONS holds the Lucide-style paths. Settings is the one workflow whose entries are not all pages (its categories, tabs; see Settings).

Canonical routes are #/<workflow>/<page>/<param>?<query>. resolve(hash) is pure: it returns the screen, or a redirect for a legacy or partial form, or missing / bad for an unknown page or undecodable parameter. syncFromUrl() applies a redirect with history.replaceState (no extra history entry), reads ?project=, and renders. Every navigation lands there.

  • Build links with href(wf, page, param, query) (LH, MH, sessHref). It carries the project scope and nothing else unless asked, which is how a workflow switch keeps the project and drops screen filters. Never write a #/… string by hand.
  • A filter worth linking to lives in the URL: a path segment for the primary one (#/learning/concepts/due, #/memory/timeline/bugfix), a query key for the rest (?domain=, ?tag=&from=&to=), listed in the page's query so it survives a project change. Selects rewrite the query with setQuery() (replace); chips navigate (push). Search text and sort order stay in T.
  • Legacy links are forever. LEGACY maps each pre-workflow route to its canonical home; #/memory and #/memory/<type> are special-cased in resolve() because canonical Memory pages share the prefix. A new route never reuses an old one's meaning.

To add a page: write viewThing(param) returning an HTML string, add it to the workflow's pages, and to a group's items if it deserves a sidebar entry (with a count in navCounts if there is an honest number). Add a row to the browser suite that loads it directly and checks the highlight. Detail pages start with <a class="page__back" data-go="${esc(LH('parent'))}">.

Collapsed groups are stored per workflow under eklavya-dash-nav-collapsed:<workflow> — only the collapsed ids, so a new group starts open; unknown ids and malformed JSON are ignored, and storage that throws means everything is open. A direct link into a collapsed group reopens it.

The drawer. At 900px and below the rail is a drawer: inert while closed, role="dialog" + aria-modal with focus held inside while open, #main and the top bar inert behind it. syncDrawer() strips every one of those on a wide screen — the desktop rail must never be hidden from assistive technology. The workflow picker is #wf-menu at the end of <body>, outside the rail's scroll clip, positioned by placePicker() and clamped to the viewport.

Adding a tip

A tip is one row of TIPS in dashboard.html; TIP does the rest (Driver.js places the bubble and beacons; TIP decides which apply and remembers what the reader saw under eklavya-dash-tips).

{ id: 'concept-filters', where: ['learning/concepts'], el: '#chips', title: 'Concept filters',
  text: 'Filter concepts by state. The filter is part of the link, so you can bookmark it.',
  side: 'bottom',                 // optional: top | right | bottom | left
  state: 'tip',                   // optional: the companion's mascot state, default tip (wink)
  when: () => S.concepts.length } // optional: only when the data makes it true
  • id is kebab-case and never reused. Renaming one shows the tip again to everyone who dismissed it: reword text instead, and give a new message a new id.
  • where lists <workflow>/<page> keys of WORKFLOWS; el is the feature on each of them. title (at most 32 characters) is the beacon's accessible name; text (at most 140) says what the feature does, plain, no emoji, no "click here". Both go through esc().
  • Order is priority: only the first eligible tip on a screen opens by itself, once; the rest stay beacons until it is dismissed. A feature that is not laid out, or sits in the sidebar while it is the phone drawer (900px and below), is not eligible.
  • TIP.sync() runs after every render() and every fill() draw, and when the drawer, a picker or the correction dialog opens or closes. A new overlay that a beacon must not sit on calls it too.
  • The registry guard in dashboard-browser.test.ts opens every where on the fixture and fails if el does not resolve to a rendered element. A typo fails CI, not a reader. Browser tests run with tips off unless they pass tips: true to open(); window.__eklavyaTestTips replaces TIPS for engine tests.

Adding a chart

All charts are hand-rolled inline SVG. No charting library, ever — the page must work with no network and no build.

chart('c-thing', (el) => chartThing(el, data));   // queues it until the DOM exists

chart() pushes into AFTER, which render() runs after innerHTML — a chart needs a laid-out parent to measure. Inside a chart function:

  • Size from el.parentElement.clientWidth and set viewBox to real pixels, so 10px axis labels stay 10px. addEventListener('resize', …) re-renders the view.
  • Fill with role tokens: var(--spot) for right, var(--spot-soft) with a var(--spot) hairline for corrected, var(--warning) for missed, var(--faint-2) for skipped. Sequential ramps are color-mix(in srgb, var(--spot) N%, var(--mass)) — a --vd-* step is legible on one ground and invisible on the other.
  • Tooltips are <title> children unless the chart already owns a .tip div.
  • Plot the empty intervals. fillDays() exists because a time axis must allocate a slot per day, not per data point: three sessions in a month must not close up into three adjacent bars. Gaps are the honest part.

Existing ones to copy from: chartActivity (stacked bars + hover tip), chartHeatmap (week columns; picks its window from the width and keeps the cell fixed; four colour steps over the visible window's busiest day; the current streak's cells outlined inside their own bounds; on a window under 53 weeks, ‹ › buttons page it by its own width through HEAT_OFF, a view offset that render() resets and the URL does not carry, redrawing only the SVG), chartForecast (14 days plus one overdue column), chartGrades (one concept's grades in order), segbar (a whole, split — plain HTML).

Tables, paging and filters

table(cols, rows), pager(key, total, page, per), page(key, total, per), slice(arr, p, per). PER is 20; a concept's question history pages at 10. page() clamps a stored page number to a list that shrank under a filter — always route through it rather than reading T[key].page directly.

Per-view control state lives in T. A filter the reader would want to link to lives in the hash instead (#/learning/concepts/due, #/learning/review/skipped, ?tag=) and is read back from param at the top of the view — the view derives it, the chip only navigates. Search text and sort order stay in T: they are typing, not destinations.

A search box sits outside every region, and what is typed in it is not part of the markup a redraw compares, so a keystroke replaces the list and nothing else: the box keeps its caret and any input method session. A future text input that filters a list does the same: outside the regions, its text in T.

Progressive disclosure: what a page shows first

The timeline set the rule and every page follows it: the answer first, one line per item, detail one click away. A page is, in order:

  1. pageHead(title, counts, about, back) — the title, one mono line of counts (.counts, joined with ·), and the page's explanation folded under a "How this works" <details class="about">. Arithmetic the honesty rules require is said there, not in a paragraph above the fold.
  2. At most four tiles() — only numbers the reader acts on. A total that is context, not a decision, belongs in the counts line.
  3. nextStep(html, ok) — the one thing waiting on the reader (due reviews, a stalled job), or nothing. Never a banner per fact.
  4. The lists. lines(items) for short lists ({ t, s, v, go }: title, quiet mono second line, one value); table() for paged ones, at most four or five columns — secondary fields go in the row's second line. A question answered is qaLine(a, lead), a <details> that opens to its options and feedback.
  5. fold(id, title, peek, body, { open, hint }) — secondary cards (calendars, grade history, settings, audit detail) as a closed <details class="card fold">. The peek is what the closed summary still answers: a count or a verdict.

Folds remember being opened or closed in localStorage under eklavya-dash-folds, keyed by id (<page>:<what>, stable across renders); storage that throws means the defaults. A chart() whose element is inside a closed <details> has no width to measure, so it waits in LAZY and the capture-phase toggle listener on #view draws it when its fold opens. All helpers take HTML the caller already escaped — esc() at the call site, as everywhere else.

The interaction contract

These are not nice-to-haves; a reader who clicks something that does nothing stops trusting the page.

  • The wordmark goes home — to the active workflow's Dashboard. renderShell() sets its href.
  • Anything that looks clickable is clickable: a row that has a detail page gets class="row" data-go="…", a stat tile whose number is a list gets a go as its fifth tiles() field, a concept name is a .link.
  • Everything clickable is keyboard-operable. focusable() gives every row and every [data-go] a tabIndex on render (and after each fill); Enter and Space are handled by the delegated keydown on #view; focus styles are --ring or a --spot outline. Never a click handler on an element nobody can Tab to.
  • All handlers are delegated on #view. The markup is replaced wholesale, a view or a region at a time, so a listener bound to an element is a listener that vanishes.
  • Navigation goes through goTo(hash), which re-renders in place when the hash is already what we want (clicking the chip you are already on).

Navigation versus adjustment. render() tells a navigation (another page, workflow, project, concept or entry) from an adjustment (a tab, a chip, a select, a date range, a pager, a search keystroke, a resize, the ground, and new data). A navigation rebuilds the shell, brings the sidebar in line, replaces #view and scrolls to the top with behavior: 'instant' (html has scroll-behavior: smooth, and a bare scroll would glide up from where the reader was). renderNav builds the rail only when the workflow changes, and otherwise updates its links, counts and open groups in place, so a link followed from the keyboard stays focused. An adjustment keeps the scroll, leaves the shell alone and redraws only what changed. It is one when the screen on show is the same page (or another route to the same screen: screen in the registry, for #/settings/user and #/settings/dashboard), in the same project, and either nothing in the URL moved or the page is inPage, which says its param and query are tabs and filters of the one screen. Mark a page inPage only when that is true: on a detail page another param is another subject. render({ nav: true }) forces a navigation for a caller that replaces what the screen is about.

A view marks what a control changes with region(id, html) (or slot(..., { region: true }) for a fetched list): a data-region, unique in the view and never nested. The markup with every region emptied is the screen's skeleton (scanRegions). drawAdjusted() compares it with the skeleton the screen was drawn from (DRAWN). When they are equal, only the regions whose markup differs are replaced, each wholesale, in the element already on the page; otherwise the view's content is replaced, still without scrolling, which is what a view with no regions, two regions of one id or nested regions gets. The comparison leaves out what the reader owns: whether a fold is open (FOLD holds it), the text in a search box (T holds it) and anything marked data-hold. A view stays a pure function of the payload, the scope and its controls, and a region is replaced, never patched by hand. pageHead makes its counts line a region of its own (head-counts, so a count that moves replaces the line and not the screen: a head that is itself in a region, as Settings' is, passes inRegion). A redraw for new data first counts a fetched region that is only its box as what it shows (withKept) and replaces nothing when the screen is then the markup it was drawn from, which also holds for a view with no regions (sameMarkup); the record of the screen is what is on screen, so the answer that arrives for such a region is compared with what it shows.

What a redraw may change: numbers, rows, badges, the sidebar's counts, the charts. What it may not change: the scroll, the focus and the caret (captureUi and restoreUi carry them, with every open <details> that has a stable key: a fold id, an entry id or a data-key), a Settings value that is typed and not saved, a choice list that is open, a date being typed, or a framed page. Focus is carried by focusKey: an id, a data-focus (or another of FOCUS_KEYS), or for a <summary> its <details>' key. A control the reader can Tab to has one; a new one without it loses focus whenever its page is redrawn, and dashboard-live-browser.test.ts fails a page that has such a control. A redraw for new data (LIVE_PASS) leaves alone a region that holds a control the reader is using (unitsInUse: the focused one, an open list wherever it is, a date box with focus, an edited Settings value), puts it on HELD_BACK, and draws it when focus leaves or the list closes (catchUp). A date box says it changed at each whole date, after the first digit of the year, and is the box the rest is typed into: typingDate leaves it in place when that is all the region's markup changes. The heatmap's page (HEAT_OFF) survives it too. TIP.sync opens the bubble that was open again when a redraw replaced another feature's element. A view with no regions still works: its content is replaced without scrolling, and only what captureUi carries (focus, the caret, open folds) survives.

Design

The visual language is .claude/skills/eklavya-design/SKILL.md; the token contract is web/CLAUDE.md. The parts this page is strict about:

  • Name a role, never a scale step or a raw hex. --ink, --dim, --faint, --faint-2, --line, --line-2, --panel, --mass, --spot, --spot-ink, --warning. Grep before you ship: a #hex or a --vd-* in this file is a bug.
  • One rhythm, owned in one place. .stack > * + * is 24px between cards; .head is 16px from a heading block to its content; .card pads 24. Nothing sets its own vertical margins — that is how the spacing drifted the last time.
  • Archivo for page titles, Inter for body at 14.5px, JetBrains Mono for slugs, micro-labels, timestamps and anything terminal-shaped. Square chrome, hairline rules, no emoji, sentence case.
  • Both grounds are the product. data-mode is applied by the head script before paint and re-applied on boot (the toggle does not exist yet when the head runs).

Settings: the first write

The Settings workflow is the dashboard's half of a promise: configuration has two interfaces, eklavya config and this page, and they change together.

  • Both write through applySetting in config-path.ts. It checks the key, coerces the value and refuses one coerce would silently drop, builds the patch against the file (siblings kept), stamps project, and calls writeConfigFile (backup, atomic rename, global-only refusal). value undefined is unset: that is eklavya config unset and the page's Inherit / Reset button. Never write a config file from dashboard.ts any other way.
  • SETTINGS in dashboard.ts is the registry the page renders (label, help, group); its type, options, range and lengths are merged in from SETTING_RULES in config-path.ts, never written here. CLI_ONLY lists keys the page shows but will not change — providers, notifications and sync, because each sends work off the machine or runs a command. dashboard.test.ts fails when a DEFAULT_CONFIG leaf is on neither list, so a new config key is placed here in the same change as its CLI help and set_config schema.
  • updateSetting leaves every value rule to applySetting (the table, then coerce, then combinations such as learn with no topic), so its 400s are the CLI's messages word for word, and checks that a project is one of configurableProjects(db) — inventory projects with a .git on disk. The page can never name an arbitrary path to write to.
  • The page validates before it POSTs: fieldProblem mirrors settingProblem in the same words, ruleHint prints the accepted range under the help, and fieldError puts a refusal — the page's or the server's — in the field's -e element with aria-invalid, keeping the typed value.
  • POST /api/settings is a row in WRITES, so acceptWrite guards it: a loopback Origin (not null: a sandboxed frame sends that), application/json (no cross-origin form can), a body of at most 256 KB, and x-eklavya-token equal to the per-start token the / response writes into <meta name="eklavya-token">. A hostile origin cannot read that page, so it cannot learn the token. The page sends it through postJson.
  • The page saves on change and redraws #settings in place (not via render()), restoring focus to the control used. Errors come back as { error } and show in that row's data-msg. saveSetting still says "saved" when a redraw for new data lands while the save is out.
  • The categories (Questions … Changed) are not a tab strip but the Settings sidebar's Preferences group: SETTINGS_TABS in the page, held to SETTINGS' groups by dashboard.test.ts, so a new group needs a row there. They are not pages: the workflow has home: false (no Dashboard entry) and tabs, navHref links each to #/settings/<dashboard|project>/<id> in the scope on show (the first one bare), and navCurrent marks the category in the URL current. Keep Danger the last group.
  • A category draws from SETS, the last /api/settings response, when settingsFresh() says it was read under the page's cursor and project: no request. One response holds both scopes, so it serves any category of either. invalidateSettings() runs on a project change and invalidateData() on a save and on new data; the next draw then reads once more. The pane is the regions sw-head, sw-body and sw-rail, so a category redraws what it changes and the scope rail stays. Arriving by #/settings/user or #/settings/dashboard is the same screen.
  • New data never redraws sw-body under a control being used: the heading, its counts and the rail still update, the rows wait, and catchUp draws them when focus leaves.

Feedback: one item, one rule

The fifth workflow shows one reviewed prompt at a time, and everything about it serves one rule: only a POST the server saw acknowledges. A GET, a page view, a reload, an item opened by URL and Back never do, and a test per path says so. Do not add a route, a query string or a render path that writes acknowledged_at.

  • The payload says that an item waits, never what it says. S.feedback has the id and counts; the prompt, better prompt, notes and tips come from /api/feedback into #fb-item through fill(). A new field on the payload must not be text the learner wrote. The item's context (feedbackContext: session, checkout, host, times, and the session's first prompt as topic) is built from evidence_events at read time, stores nothing, and is served only by /api/feedback.
  • The badge is S.feedback.notify, not pending: a stale item from before feedback or memory was switched off waits quietly. syncBadge() runs from renderShell(), sets the caret's label (Switch workflow, 1 feedback unread) and the visible 1 on every workflow; the picker item says Feedback, 1 unread. The badge is --warning with --bg text, aria-hidden, and never animates.
  • via is how the page was reached: the greeting link carries ?via=greeting, the picker ?via=badge, anything else is direct. fbOpened() POSTs it once per opening (FB.counted, reset when another page renders, so a resize does not count twice) and then drops via from the URL with replaceState. It is declared in the page's query list so the router keeps it.
  • Acknowledge and Delete go through postJson, disable while in flight, and end in fbDone(): redraw from the response's state, focus the heading, and announce through #fb-live, which is outside #view so it exists before its text does. Delete confirms inline; there is no confirm() dialog.
  • The item is a story column and a rail. The verdict (fbVerdict: a headline and the coverage strip, built from the gap areas and review.areas, never from model text or a number; an item from rubric 2 or older says "Not flagged" for an area its gaps do not name), the two prompts, what worked, the gaps and the tips, then the rail: "Where you said it" and .fb__decide, which holds the rule sentence (said once, beside the button; the .fb__rule banner is only for the empty state), Acknowledge and Delete. The rail is sticky above 900px. At 900px and below a fixed .fb__bar holds a second data-fb-ack button and the rail's is hidden, so exactly one is visible; pressing either disables both.
  • The "Found a problem with Eklavya?" line is not a tip. It is information shown in place, not a nudge, so it is not in TIPS. Dismissal is eklavya-dash-feedback-tip in localStorage; storage that throws shows the line, the opposite of the tips engine, because it is information and not a nudge.

Loopback is not the boundary it looks like

startDashboard refuses any request whose Host is not a loopback name, and any cross-origin Origin that is not loopback either. Do not remove that check and do not widen it to "starts with 127.": a page the developer has open can point a hostname it controls at loopback and fetch from here, and the browser's same-origin rule does not stop it because the page's origin is that hostname.

Two consequences for anything added here. A new endpoint inherits the check because it sits behind the same handler — keep it that way rather than registering a second server. And a mutating endpoint needs more than this check: every route in WRITES goes through acceptWrite, which adds the page token, a required loopback Origin, a JSON content type and a size cap. A new write is one WRITES row and one WriteHandler; the page calls it with postJson(path, body), never its own fetch. The table-driven guard test in dashboard.test.ts covers every row, so the new route inherits it.

An artifact page is not the dashboard

The one frame: the viewer. Every page opens at #/artifacts/view/<id>, as a tab: an <iframe> of /artifacts/<id>?embed with sandbox lacking allow-same-origin (never add it), plus, for an explainer whose correction is not null, the pinned correction bar and a native <dialog> for the modal. ?embed goes through embedHtml: it strips the template's Google Fonts links, renames a 1.46.0 page's own height message out of the race, and adds EMBED_SCRIPT before </body>, which hides the page's overflow and reports where an empty block appended after the content sits (it can shrink, and it ignores html,body{height:100%}; scrollHeight does neither). The page's HTML button serializes the live document, so the script leaves nothing in it: it removes its own element, keeps its rules in a constructed stylesheet (adoptedStyleSheets, which outerHTML never writes) and adds no class to the root; only the measuring block stays. A file saved from the viewer must scroll on its own, and the browser suite checks it. Content that grows with the frame (a 100vh box) outruns three reports in a row; the page then sends eklavya:scroll and the viewer gives it the 80vh frame to scroll in. Nothing is reported while the frame has no width: one word per line measures past the cap, and a frame stuck at the cap never resizes, so the outrun count never reaches three. The response carries ARTIFACT_EMBED_CSP (no font hosts), so the viewer keeps the no-outbound-request promise. The framed page may only postMessage its height (eklavya:height, accepted from that frame's contentWindow alone, and clamped to [200, 20000]) and receive the ground (eklavya:mode); it never writes. The frame sits in a box (.viewer__box, data-hold) at the height that tab last reported (TABS.height), else 80vh (--wait-h), with the loader over its top for a tab not opened before, so opening a page reflows once, when the real height arrives. After 1.5s without a report the frame falls back to 80vh and scrolls inside. The box and the correction bar are data-hold, so a redraw of the screen (new data, the ground) leaves them in the document: the frame is the same element, its page is not loaded again, and the ground reaches it by message. The tab strip is a region.

TABS holds the open tabs (at most TAB_MAX, persisted under eklavya-dash-tabs, least-recently-viewed eviction that spares open corrections); drawTabs(active) draws the strip at the top of the gallery and the viewer. The URL names the active tab. Scroll per tab lives in memory and is restored once the page is tall enough to reach it, or abandoned when the reader scrolls first. Every write is page code calling postJson('/api/attempts/retry', …); the server grades and never sends correct. armLeave holds a beforeunload prompt while the bar is open and render() disarms it on every navigation. The resize handler skips the viewer: nothing on it depends on the width, and its frame reports its own height. In the browser suite, ready() counts the page's own open requests: Playwright never reports networkidle again once a sandboxed frame has attached.

/artifacts/<folder>/<file> serves HTML an agent wrote, from this origin. It goes out with ARTIFACT_CSP instead of the page's CSP: sandbox without allow-same-origin gives it an opaque origin, so its scripts run (the PDF and HTML buttons need them) but a fetch to /api/state is refused, and default-src 'none' plus the Google Fonts hosts is all it may load. The page opens every page in the viewer; the viewer's Open in new browser tab is the one plain <a target="_blank" rel="noopener"> to the raw file, never data-go. resolveArtifact is the only way from a URL to a file; do not join paths here. test/artifacts.test.ts covers traversal, a planted symlink and the rebound host, and the browser suite checks the opened page cannot read the API.

The Artifacts Dashboard is a gallery: one .art card per page, its thumbnail an <img> of /artifacts/<id>?thumb=ink|paper&v=<bytes>. artifactThumb cuts the page's first <figure> <svg> out with the page's <style> blocks and sets data-mode on its root; a page with no diagram gets the cover sheet. An image runs no script and loads nothing, which is why this is safe where an iframe is not. eklavyaGround.apply swaps every img[data-thumb] to the new ground; v busts the hour-long cache when a page is rewritten.

The daily card engine

One card, once a local day, on arrival at #/learning/dashboard. assets/daily-card.cjs (EklavyaDaily, served at /daily-card.js) is pure: decide(sig, mem, ctx) gates (off, shown today, no answers, wrong route, busy), rank scores every card whose condition holds (a base, an urgency term, minus REPEAT_COST for a card shown in the last three days) and the top score wins, a tie going to ORDER. remember returns the memory to store. The page owns the rest: DAILY.signals() reads numbers the page already derives (never a query for the card), DAILY.arrive() runs only after a navigation (never an adjustment or LIVE_PASS), DAILY_CARDS draws one card per id, and memory lives in localStorage like the tips (storage that throws means no card). Both switches are on Settings, Companion (companionSwitch), with delegated click handlers because that tab is redrawn. The engine is held to 100% of its lines, statements, functions and branches in .c8rc.json. Browser tests turn it off in open() unless they pass daily: true, as they do tips. A new card is a row in the engine's ORDER, MOOD, rank and GREETING, a drawing in DAILY_CARDS, a case in daily-card.test.ts and a row in the manual's table.

Core components: toast, dialog and sheet

Three shared pieces live in dashboard.html; a page never writes its own popup. toast.success|caution|alert(text, { title, id, action }) and toast.dismiss(id) show a corner message with the archer's face (success happy, caution worried, alert sad); copyCommand uses it for every Copy button. ekDialog({ state, title, text, ok, cancel, input, danger, single }) is the shared decision dialog (native <dialog id="dlg">, resolves { ok, value }, focus on Cancel when danger). The review page's #sheet is a native side dialog filled by openSheet(slug); it is emptied on close and holds the only #copy-live that exists while it is open. Tests: dashboard-learning-browser.test.ts.

Escaping is not optional

Learning rows are written by the tutor. Memory rows are arbitrary tool output and developer prose, and this page is served on loopback from the same origin as everything else on the machine — one unescaped ${e.title} is stored XSS. Every stored string goes through esc() on its way into a template, including inside data-* attributes, and dashboard.test.ts has a case that scans this file for a stored field interpolated without it. Raw evidence bodies go in a <pre class="raw">, escaped, capped server-side, and marked when truncated.

Honesty rules

The page is a record of someone's learning; it does not get to flatter them.

  • The catalogue is a library, not a to-do list. Untouched concepts are never debt, never a percentage-complete, never a nag.
  • Say the arithmetic out loud where it decides something: mastery is score ≥ 0.7 with ≥ 2 reps, decay is ~5%/week overdue, a promotion needs answers and accuracy and distinct concepts.
  • Disclose every cap. attempts_shown < attempts_total and logged_shown < logged_total each print a line in the footer rather than letting a truncated history look complete. A cap shortens a list and never a number.
  • Keep the pre-migration rows. A null repo is "recorded before projects were tracked" and stays visible; dropping it would shrink every total on the page.
  • Scope is activity, not mastery: the project selector narrows attempts, sessions and the concept list, but a concept you know is a concept you know, so scores and review dates never change with it.
  • Captured is not assessed. Six words for six different things, never one "memories" number: evidence can be captured and never processed, an entry indexed and never retrieved, retrieved and never delivered, delivered and never asked about. Only assessed required the developer to answer something.
  • A receipt no hook wrote out is never a saving. savingsFrom in memory/tokens.ts is the only place the percentage is computed and it refuses to divide unless the delivery was emitted (or a legacy confirmed, the same fact); the page shows unknown and prepared rows in the ledger with no verdict beside them. Never call emitted context host-confirmed: Claude Code does not acknowledge hook context. Say "estimated context volume", never a cost or a bill.
  • Superseded and deleted rows stay visible, marked. The timeline is the audit trail — a correction that erases what it corrected is not one. Strike the title as well as labelling it: colour alone is not a marker.

Before you call it done

cd mcp && npm test          # builds, then everything — the browser suite included
node dist/cli.js dashboard --port 41799 --no-open
  1. Every section and both detail levels, at 1280, 900 and 560, in both grounds. A bug that only shows on paper is the commonest kind.
  2. No console errors, no outbound request, and scrollWidth <= clientWidth at 560 and 390 — dashboard-browser.test.ts checks all three on every workflow page; add a new page to its list.
  3. Click the thing you added, then Tab to it and press Enter.
  4. Payload changes get a case in mcp/test/dashboard.test.ts. A change to the stream, the watcher or the memo gets one in dashboard-events.test.ts or dashboard-memo.test.ts, and a change to what a redraw keeps gets one in the in-page, fills or live browser suites. Run scripts/dashboard-perf.mjs before and after a change to a builder, a query, a cap or what a route caches.
  5. Docs ship in the same commit — this is the repo contract, not a follow-up: web/src/content/docs/docs/dashboard.mdx (sections, routes, page sizes), the #dashboard block in web/public/index.html, and the route table in user-skill/eklavya/SKILL.md so chat can hand back #/learning/review rather than the bare root. A new route that nothing documents is a route nobody will find.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.