Code intelligence engine that maps repository structure, dependencies, and relationships into a graph for developers and AI agents.
A live, local map of what your AI coding agents are doing the plan they declared, the files they actually touched, and every place those two stop agreeing.
Quickstart · Dashboard · PLAN.md · Extending
MIT · zero runtime dependencies · nothing leaves your machine
cd your-repo
npx driftline init # one-time: local agent hooks + a starter PLAN.md
npx driftline --open # http://127.0.0.1:4173That's the whole install. There is no npm install step and no config file
driftline has zero runtime dependencies, because SQLite comes from
node:sqlite, the watcher from fs.watch, and the server from node:http.
Requires Node 22.5+.
Want to see it without wiring anything up? Clone this repo and run it on
itself — PLAN.md here describes driftline, so you get a real map in five
seconds:
git clone https://github.com/justusa11/driftline && cd driftline
node bin/driftline.mjs --open| A map that draws itself | Components and dependencies come from PLAN.md. Topological columns, crossing-reduced rows, click any card to focus its neighbourhood. |
| Observed activity, attributed | Every write is matched back to the component that declared those files. Cards light up as their files move. |
| Drift | A component says [x] done, but its files kept changing afterwards. Badged on the card, counted in the legend. |
| Stalled work | A component says [~] working, but nothing has moved in ten minutes. The answer to "is it stuck, or is it thinking?" |
| Plan coverage | The glob matcher run backwards: what percentage of observed writes does your plan actually claim? With the unmapped directories listed and paste-ready stubs for them. |
| Replay | Scrub or play the entire session back. The map, the feed, the file heat and every timestamp rewind together — including the plan itself, so components that hadn't been declared yet are genuinely absent. |
Watching an agent work, you lose the thread fast. It reports success on a task while quietly editing three files nobody asked it to touch; it marks something done and keeps changing it; it goes quiet and you can't tell whether it's stuck or thinking. The plan and the work drift apart, and nothing shows you the gap.
driftline holds the two side by side: what was declared (PLAN.md) and
what was observed (every write, every tool call). The interesting part is
never either one on its own — it's where they disagree.
Credit where it's due: the core idea — declared plan vs. observed filesystem activity, as a live local map comes from sodiumsun/agenttrail. driftline is a from-scratch rebuild with a different set of architectural trade-offs, listed below.
bin/driftline.mjs CLI entry: `init` and `--open`
lib/server.mjs HTTP + SSE daemon, binds 127.0.0.1 only
lib/store.mjs node:sqlite event log + PLAN.md version history
lib/watcher.mjs fs.watch observer + the shared ignore filter
lib/planParser.mjs Hand-written grammar for PLAN.md (throws with line numbers)
lib/adapters.mjs Canonical event schema + per-agent translators
lib/install.mjs `init`: hooks + starter plan, additive/idempotent
public/index.html Single-file dashboard: file tree + map + live feed
test/planParser.test.mjs
-
Persistent SQLite event log instead of in-memory fading state. Uses
node:sqlite(built into Node 22+, no npm install required) so the zero-dependency philosophy survives. You get restart-proof history — and everything below is that decision being spent. -
Replay, and it's honest about the past. The log also keeps a copy of
PLAN.mdevery time its content changes (plan_versions, deduped by hash). So scrubbing the timeline doesn't project today's plan onto yesterday's activity: components that hadn't been declared yet are genuinely missing, and tasks show the status they had at that instant. Press play and watch a component get added mid-session. -
"Is it stuck?" as a first-class state. A component declaring
[~] workingwhosefiles:haven't moved in ten minutes is markedstalled 26m— amber card, amber accent, counted in the legend. The declared status and the observed activity are both already there; nothing else seems to bother comparing them. -
Coverage: the plan checked against reality. Run the glob matcher backwards over observed writes and the ones no component claims are, by definition, work nobody declared. The Coverage tab puts a number on it — "52% of the 23 observed writes landed in files a component declares" — lists the unmapped directories, and hands you a paste-ready component stub for each.
-
Sessions instead of an undifferentiated stream.
session_idwas on every row and used for nothing. Rolled up, it answers "what happened while I was at lunch". The fs watcher can't attribute a write to an agent, so observed writes are folded into whichever agent session was live at that moment rather than pretending to be a session of their own. -
A real grammar for
PLAN.md, not regex scanning.lib/planParser.mjsis a small tokenizer/parser with explicit rules. Malformed input throws aPlanParseErrorwith a line number instead of silently mis-rendering the map. It's unit tested (test/planParser.test.mjs) — the convention itself is now a contract, not tribal knowledge. -
Adapter interface for agents.
lib/adapters.mjsdefines one canonical event shape; each agent gets aregisterAdapter(name, translate)function. Claude Code's hook payloads are translated in ~30 lines. Adding Codex, Cursor's own hook format (if/when it exposes one), or a custom in-house agent means writing one adapter file — the daemon, store, and UI never change. -
Deterministic layered layout instead of hand-placed nodes. Columns come from topological depth on
needs:; row order inside each column comes from a barycenter sweep, the same crossing-reduction idea a Sugiyama layout uses, in about thirty lines and with no D3/dagre dependency. Past ~15 components that ordering pass is most of the difference between a readable graph and a hairball. -
The cards are DOM; only the wires are SVG. Components are absolutely positioned
<div>s inside one transformed viewport, with an SVG layer behind them for edges. Progress bars, expandable task lists, hover states and text ellipsis are then just CSS, and the edge layer stays cheap enough to redraw on every drag frame. -
Observed writes are attributed back to declared components. Each component's
files:globs are compiled once, so a write tobackend/twin/rc.pylights up the card that claimed it. If a component says[x] doneand its files keep changing afterwards, it gets adriftbadge — that disagreement between declared and observed is the reason the tool exists, so the dashboard names it instead of leaving it for you to notice. -
Still local-only, still no telemetry. Binds
127.0.0.1explicitly (never0.0.0.0). Optional--tokensupport is the natural next step if you ever run this on a shared machine.
## Capture the audio {#capture}
tech: coreaudio tap + ring buffer
files: [src/audio/**]
- [x] Grab the mic feed {#capture-mic}
- [~] Keep the last 30 seconds ready {#capture-ring}
## decisions
- 2026-08-21: dropped redis for summaries; in-process queue instead
[ ] todo · [x] done · [~] working · [!] blocked
needs: draws a dependency arrow · links: draws a dashed connection
Add an agent adapter — in lib/adapters.mjs:
registerAdapter('my-agent', (rawPayload) => ({
ts: Date.now(),
agent: 'my-agent',
sessionId: rawPayload.session,
kind: 'tool_end',
tool: rawPayload.action,
file: rawPayload.path,
}));Then add a route in lib/server.mjs that POSTs incoming payloads through it.
Run the tests:
npm test
.venv*, venv*, __pycache__, .pytest_cache, .mypy_cache, .tox,
site-packages, htmlcov, coverage, and *.pyc/*.pyo are ignored by
default alongside the usual node_modules/.git/dist/build set.
Patterns support * and ? (within a path segment), so the *.log the
starter file suggests actually works and the watcher and the dashboard's
file tree share one filter, so the tree can never show you a path the feed
would never mention. driftline init also drops a
.driftlineignore file in the repo root one path per line, # comments
allowed for anything project-specific still showing up in Live Activity.
Already ran init before this existed? Re-run it — it's additive and won't
touch your PLAN.md.
Three panes over one event log. Picking something in any one of them filters the other two:
-
Files the repo tree, coloured by how recently the event log saw each path change. Click a file or directory to scope the activity feed to it and highlight the components that declared it.
Touchednarrows the tree to just the paths that have actually moved. -
Map
PLAN.mdas a dependency graph. Click a card to focus it: its wires light up, everything unrelated recedes, and the right pane becomes a detail view (tasks, dependencies, dependents, declared globs, and the activity matching those globs). TheN tasksbutton expands the task list inline on the card. -
Activity the observed stream, with repeated hits on one file coalesced into a single
×nrun instead of forty identical rows. Filter by kind, search by path/tool/agent, and click a#componenttag to jump to that card. A 60-minute sparkline sits above it. -
Coverage how much observed work the plan actually claims, and which directories it doesn't. Click an unmapped area to scope the feed to it, or
stubto copy a component for it. -
Timeline the scrubber along the bottom. Density of events across the window, agent sessions as bands underneath, and a tick wherever
PLAN.mditself changed. Drag it and the whole dashboard — map, feed, file heat, coverage, and every relative timestamp — rewinds to that instant.Spaceplays it back;Return to live(orEsc) rejoins the present.
The status legend doubles as a live tally and a filter — including
drift (done, but the files kept moving) and stalled (working, but
nothing has moved in a while), which only appear when something is
actually in that state.
Keyboard: F fit · + - 0 zoom · T files · A activity · /
search · Esc clear the current focus. Drag a card to place it by hand;
card positions and the pan/zoom persist per repo, and Re-arrange
discards them and re-runs the auto-layout.
The header pill answers "is anything actually happening": Working while
events arrive, Watching plus how long it's been quiet once they stop,
and reconnecting… if the daemon goes away.
The GIF at the top isn't a screen recording. npm run demo regenerates it
from scratch:
- a fixed fixture (
tools/demo-fixture.mjs) is written straight into a throwaway store with chosen timestamps — the same 38 minutes of history every run, including twoPLAN.mdrevisions - the recorder drives the replay playhead itself, frame by frame, so the animation is a function of frame index rather than of how fast the machine can screenshot
- frames are encoded to GIF in-process (
tools/lib/), so this needs no ffmpeg and no npm packages — same zero-dependency rule as the product
Change the UI, run it again, get the same demo with the new look. The
encoder is unit-tested by round-tripping its own output back through an
independent decoder (test/gif.test.mjs).
--tokenflag wired through to the dashboard'sEventSourcefor shared-machine use- Codex/Cursor adapters (they don't yet expose hook systems as rich as Claude Code's, so today they're covered only by the fs watcher)
- Replay currently loads its window of events in one request. That's fine for a day of work and would want paging for a month of it.
- Coverage counts writes, not lines. Weighting by diff size would sharpen it, but no adapter reports that yet.
MIT
