Skip to content
chrisjowenPublic

About

Local-first team memory for software repositories. Markdown files are canonical; indexes are disposable projections.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

A manila case file labelled .docket/ holding a Markdown exhibit stamped EVIDENCE, beside a cork board of exhibit cards joined by red string

docket

The casebook of evidence for your repository.

Every claim about your system, entered as an exhibit and cited to its source, kept in the repo, and a Claude Code plugin that reads the case file first and enters new evidence as it works.

The case file · An exhibit · How to use it · Install · Browse it


Which services exist, who owns them, what depends on what, why the datastore decision went the way it did: that knowledge is usually hearsay, scattered across people's heads, stale wikis and chat threads. docket keeps it on the record. Each fact is an exhibit, a plain Markdown file with YAML frontmatter under .docket/, and each claim in it cites its evidence: the file and lines, manifest key, endpoint, URL or conversation it was seen in. docket weighs that evidence into a confidence score; nobody writes one by hand.

The files are the record. Indexes, vector stores and graphs are disposable projections of them, deleted and rebuilt byte-for-byte whenever you like:

rm -rf .docket/.index
docket rebuild

The case file

docket init opens the case: a config file at the root and a .docket/ tree beside your code. Humans and agents edit the same files, and Git keeps the history.

your-repo/
├── .docket.yaml               config: where the case file lives, which adapters to feed
└── .docket/
    ├── entities.yaml          the ontology: resource types, relationships, evidence sources
    ├── resources/             exhibits, one Markdown file per thing
    │   ├── services/orders.md         id: service.orders
    │   ├── teams/platform-engineering.md
    │   ├── datasources/orders-db.md
    │   └── ...                repositories, libraries, agents, systems, environments
    ├── decisions/             why things are the way they are, losers included
    │   └── orders-on-postgres.md
    ├── constraints/           rules the system must obey
    │   └── pci-scope.md
    ├── notes/                 anything else worth keeping
    ├── .index/                projections: generated, gitignored, disposable
    │   ├── documents.jsonl
    │   ├── nodes.jsonl
    │   ├── edges.jsonl
    │   ├── manifests/         what sync handed each adapter instance: local.json, ...
    │   └── adapters/          state an adapter instance keeps for itself, if any
    └── .cache/                chat answers and when each adapter last synced: gitignored, disposable

Directories are only for people: an exhibit's identity is the id in its frontmatter, never its path, so files can move freely. The ontology in entities.yaml is per-repository. It starts as the default SDLC ontology (services, APIs, pipelines, clusters, incidents, runbooks, policies, teams, decisions, constraints and more) and you extend it when your case needs a type it does not have. examples/acme-platform is a complete worked case file: 21 resources and 37 links.

An exhibit

---
id: service.conversation-api          # <type>.<semantic-name>; never changes when the file moves
type: service                         # a resource type from entities.yaml
title: Conversation API

attributes:
  language: typescript
  lifecycle: active

links:                                # typed relationships to other exhibits
  - rel: owned_by
    target: team.platform

  - rel: depends_on
    target: datasource.sessions
    attributes:
      criticality: high
      runtime: true
    evidence:                         # where this link was seen
      - source: code
        path: src/store/sessions.ts
        lines: 12-40
        observedAt: 2026-10-05
        observedBy: claude

evidence:                             # where the service itself was seen
  - source: manifest
    path: services/conversation-api/package.json
    key: name
    observedAt: 2026-10-05
    observedBy: claude
---

# Conversation API

Handles conversation persistence and retrieval.

Only id, type and title are required. Links may point at exhibits no file defines yet: the graph is built incrementally, so a dangling reference is a warning until you ask for --strict. See Evidence and confidence for how evidence becomes a score.

How to use it

1. Open the case. From the root of your repository:

npx @chrisjowen/docket setup

This installs the Claude Code plugin, offers to run docket init to create .docket.yaml, .docket/ and the default ontology, and offers to install the docket CLI globally. Restart Claude Code to load the plugin. Install covers teams, CI and doing it by hand.

2. Enter evidence. Ask Claude to "remember" a fact, decision or constraint, or let it notice one: the plugin's remember skill writes the exhibit and cites where it saw it, and an end-of-session review captures what a session settled. You can write or edit exhibits by hand just as well. Either way, check them against the ontology:

docket validate --strict

3. Index the record. Projections are what make the case file searchable:

docket sync         # project changed files into .docket/.index (and mem0 or Neo4j, if configured)
docket watch        # or keep projecting continuously as files change
docket rebuild      # throw every projection away and rebuild it from the files

4. Question the witness. Search every projection at once; each hit points at its canonical file:

docket search who owns checkout
docket ask "how many services depend on the ledger?"
docket ontology show service    # a service's attributes, links and what each source is worth

The plugin teaches Claude to ask the docket before it greps the code.

5. Review the case. docket open serves a web UI to browse the graph, read each exhibit with its evidence and confidence, and ask questions. See Browse it.

Install

One command, from the root of the repository you want to remember things:

npx @chrisjowen/docket setup

It installs the Claude Code plugin (through the claude CLI, for your user), offers to run docket init if the repository has no .docket.yaml, and offers to install the CLI globally so docket works in your shell too. Run it again whenever you like: it updates what is already installed and leaves an existing .docket.yaml alone. Restart Claude Code afterwards to load the plugin.

Without a terminal (CI, scripts) it asks nothing: it installs the plugin and skips the rest unless told otherwise. --yes takes the recommended answer to every question (set up the repository, but no global install), --no-init and --no-global skip those offers, and --global installs the CLI without asking. Node 22 or later.

For a team

npx @chrisjowen/docket setup --team

installs the plugin for the repository instead of for you: it records the marketplace and the plugin in .claude/settings.json. Commit that file, and everyone who clones the repository and trusts it in Claude Code is offered the plugin.

Just the plugin

Without the claude CLI on your path, or to do it by hand, install the plugin from inside Claude Code:

/plugin marketplace add chrisjowen/docket
/plugin install docket@docket

The plugin needs no separate CLI install. It runs the repository's own docket, or one on your path, and otherwise the CLI release it pins, through npx (so Node 22 or later is still needed). Set up a repository with npx @chrisjowen/docket init.

As a dev dependency

pnpm add -D @chrisjowen/docket   # or: npm install --save-dev @chrisjowen/docket
npx docket init

The command is docket; run it through npx (or a package.json script) when it is installed as a dev dependency. The plugin uses this copy first.

Command reference

docket setup                # install the Claude Code plugin and set up this repository
docket init                 # scaffold .docket.yaml, .docket/ and the default ontology
docket validate             # check files against the ontology
docket validate --strict    # unresolved links become errors
docket sync                 # project changed files into .docket/.index
docket sync --adapter <id>  # sync only that adapter instance (repeatable)
docket rebuild              # reset and reproject everything
docket rebuild --adapter <id>  # reset and reproject only that instance, in its own namespace
docket watch                # reconcile continuously as files change
docket ask <question...>    # ask the adapters through the coordinator: each answer with its evidence, and a summary
docket ask --json <question...>  # the coordinated answer as JSON: blocks, evidence, diagnostics (no summary unless --synthesis)
docket ask --adapter <id> --no-synthesis <question...>  # one adapter (repeatable), without a summary
docket search <query...>    # the documents each adapter found: ask's compatibility view
docket adapters list        # the configured adapter instances, without connecting to any
docket adapters status      # each instance's health and how far its index lags the files (--json)
docket graph --json         # every exhibit and link, read from the files, as versioned JSON (--root <dir>; docs/graph-json.md)
docket open                 # browse, search, ask and chat in a web UI on 127.0.0.1 (--host to widen)
docket ontology list        # resource types, relationships and evidence sources
docket ontology show service  # attributes, relationships and confidence by source
docket adapter add <provider>    # set up jsonl, neo4j, mem0, memvid or mempalace: asks, shows the plan, applies it once confirmed
docket adapters list        # configured adapter instances and whether each can load
docket config migrate --dry-run  # show a version 1 .docket.yaml rewritten as version 2
docket config migrate       # rewrite it, once confirmed (--write: without asking); the original is kept
docket runtime plan <id>    # validate a runtimes entry and show what it would run, secrets redacted
docket runtime up <id>      # start its containers, pulling only as its pullPolicy allows
docket runtime status <id>  # state and health of its containers, changing nothing
docket runtime down <id>    # stop and remove its containers; volumes are kept (--destroy-volumes deletes them)

Browse it: docket open

docket open on the acme-platform example: the Browse graph with the Research Assistant agent selected, and its confidence, sources and three pieces of evidence in the Inspector

docket open serves a web UI for the repository on port 4380 and prints http://127.0.0.1:4380/ (or any free port when that one is taken; --port picks one, --no-open skips launching the browser). It reads the canonical files on every request, so it needs no projection beyond the default jsonl. It listens on 127.0.0.1 only, so nothing else on the network can reach it. --host picks another interface: --host 0.0.0.0 serves every interface, and then anyone who can reach the machine on that port can read the repository's knowledge, whatever Host they ask for. Syncing stays limited to the page at 127.0.0.1 either way, so a server listening only on another address cannot sync.

Behaviour change: earlier versions of docket open listened on every interface (0.0.0.0) by default. To keep reaching the UI from other machines, start it with docket open --host 0.0.0.0.

The API never changes the canonical files; its writes are chat's answer cache and, when the page asks from this machine, a sync of the projections.

  • Graph - every entity and relationship as a force-directed graph, each type drawn with its own icon and colour, filtered by type and relationship (each with All / None switches). Links to entities no file defines yet show as dashed ghosts.

  • Inspector - an entity's frontmatter, notes and links in and out, with the confidence docket computes for it and for each link, the sources that corroborate it, and every piece of evidence - file and lines, endpoint, URLs, when and by whom - merged from all the files that declare its id. Each link expands to show the evidence for that relationship.

  • Quick search (⌘K or /) - instant type-ahead over ids, titles, types, tags, attributes, notes and relationship names. type:service, rel:depends_on and tag:core narrow it down; picking a result focuses the graph on it.

  • Table and History - beside the graph under Browse: every exhibit or observation as a sortable table, filtered by type, source, date observed and assessment; and the dated observations over time - when something happened (eventAt) and when it was observed (observedAt), labelled apart - with each chain of supersedes decisions.

  • Sync - the header's Sync button runs docket sync - the same code, not a copy - for every adapter enabled for projection or just one, showing each adapter's result as its pass finishes, when each last synced, and a hint when files changed since. One sync runs at a time. Only the page on the machine running docket open, at 127.0.0.1, can start one: the server checks the request's address, Host and Origin, and a token it makes at launch.

  • Ask - its own page: a question put to every adapter configured for query, each answer shown as it came - exhibits, passages, facts, counts, tables, timelines, graphs - with how the adapter read the question, how much it covered, and the evidence behind each result, which opens in the inspector beside it. One adapter failing leaves the others' answers in place, and counts that disagree are shown side by side. Ask goes through the same coordinator as docket ask (see Asking questions), with the relationship paths that join what the adapters found; "Show on the board" puts the results, and only them, on the graph. Clicking evidence opens it with what the files hold at its reference, read when you click. Adapters answer from their projections, so run docket sync first; the UI says when an adapter is behind the files.

  • Adapters - each configured adapter's roles, connection health and how far its index lags the files. It never shows configuration, which can hold secrets, and viewing it never starts or pulls anything.

  • Chat - a conversation with the casebook. Each question runs the same search as Ask, then a model summarizes what it found in a few sentences, citing each exhibit it relies on; citations link to the exhibit, and the graph shows only the exhibits cited (everything found when it cites none). The model is Claude, through the claude CLI from Claude Code - nothing to configure, it uses your own login. docket runs it in print mode (claude -p) with the question on stdin, no tools, no MCP servers and no saved session, and gives up after two minutes. To pick its model, or to summarize with a local Ollama model instead, set summarize in .docket.yaml:

    summarize:
      provider: claude
      model: sonnet                      # passed as `claude --model`; unset, Claude Code picks
      # timeoutMs: 120000
    summarize:
      model: "qwen2.5:7b"                # Ollama at http://localhost:11434
      # provider: ollama                 # the default when `provider` is left out
      # url: http://localhost:11434
      # timeoutMs: 60000

    When claude is not installed and no Ollama model is set - or when the model fails - chat shows what search found, unsummarized, and says why.

    Summaries are cached in .docket/.cache/chat/, one JSON file per question, keyed by a hash of the question (ignoring case and spacing), the model and its provider, its instructions, the content hash of every exhibit the summary was built from and the relationship paths connecting them. Asking the same question again returns the cached answer at once, marked as such, while those are unchanged; editing any file that declares one of the exhibits, finding a different set, a change in how they connect, or changing the model or provider asks the model afresh and replaces the entry. Like .index, the cache is disposable: docket init adds .docket/.cache/ to .gitignore, and deleting it only costs the next answer a model call.

The UI is a SvelteKit app in packages/docket-ui, built into the npm package, so npm i -g @chrisjowen/docket is all it needs.

The API it reads is plain JSON, for scripts too: GET /api/graph returns the graph docket graph --json prints, without its schema and version (scripts should read the command, whose contract is versioned: docs/graph-json.md); POST /api/ask (a question, optional adapters, conversation, synthesis, timezone and requestId) returns what docket ask --json prints; DELETE /api/ask/<requestId> cancels a question still being answered, as closing its connection does; GET /api/adapters returns what docket adapters status --json prints; and POST /api/evidence resolves up to 16 canonical references against the files, with a bounded excerpt of each. The older GET /api/ask?q= and GET /api/chat?q= still answer, translated from the coordinator's answers.

Asking questions: docket ask

docket ask puts a question to every adapter enabled for query - or those --adapter names - through one shared coordinator (docs/adapter-spec.md §10). The UI's Ask page and docket search use the same coordinator, so they agree.

  • Each adapter answers in its own terms: entities and passages from keyword or semantic search, counts and typed tables from a graph query, facts, timelines. Nothing is merged into one score; a count from a graph is never turned into a list of documents.
  • They are asked at most query.maxConcurrentAdapters at once, all within one query.timeoutMs deadline. One that fails, breaks the adapter contract or misses the deadline is reported as that adapter's failure; the others' answers stand. Ctrl-C (or closing the UI's request) cancels the adapters still answering.
  • Every answer is validated against the contract, and every canonical reference in it is checked against the files as they stood when the question was asked: a result naming a record the files do not hold in scope is left out with a warning, a reference to another scope is dropped, and a reference to an older revision is marked as coming from an index behind the files. Block and evidence ids are prefixed with the adapter instance id, so graph:row-1 is unique across the answer.
  • Coverage is stated per adapter: every match only when the engine enumerated everything matching in scope, otherwise top matches only - a sample, never a count.
  • Unless --no-synthesis (or query.synthesis: false), the summarize model writes a short answer from the results, citing exhibits as [service.orders] and evidence as [graph:row-1]: it is told to say which facts were derived by an engine, what each result covers, and to give disagreeing counts side by side rather than choose one. Its prompt holds the most relevant parts of each exhibit and piece of evidence - including the entities on the paths between what was found - within one total budget. Summaries are cached in .docket/.cache/chat/, keyed by the question and how it was read (scope, time zone, the day, the conversation), each adapter's configuration, checkpoint and answer, the revisions of what it cites, the paths between them, the model and its instructions. An adapter whose freshness is unknown makes its summary uncacheable.

--json prints the whole coordinated answer - per-adapter blocks, evidence, the status of each evidence reference, freshness and diagnostics, every canonical reference found once with the adapters that found it, and the paths between them - and leaves the summary out unless --synthesis asks for it. --timezone sets the zone relative times are read in.

Evidence and confidence

A captured resource or link says exactly where it was seen, as evidence: a source kind and the location — file, line range and symbol; config key; API method and endpoint; URL — with when, by whom and in which session.

evidence:
  - source: infrastructure
    path: deploy/k8s/orders/deployment.yaml
    lines: 1-48
    symbol: Deployment/orders-api
    urls:
      - https://github.com/acme/platform/blob/3f2c1d0/deploy/k8s/orders/deployment.yaml#L1-L48
    observedAt: 2026-10-05
    observedBy: claude
    session: 6c1f0e2a
    note: Deployment with 3 replicas of the orders-api image.

Links take an evidence list of their own. Evidence is append-only: seeing something again adds an entry and never rewrites an earlier one.

Confidence is computed, never written. The ontology says what one observation from each source is worth, per resource type and relationship — a dependency declared in a manifest is close to certain, a pod or secret named in code is not much until something independent confirms it:

# .docket/entities.yaml
evidence:
  sources:
    code: { confidence: 0.6, requires: [path] }
    runtime: { confidence: 0.85, requiresAny: [urls, endpoint, symbol] }

resourceTypes:
  pod:
    confidence: { code: 0.3, infrastructure: 0.7, runtime: 0.9 }

relationships:
  depends_on:
    confidence: { manifest: 0.95, code: 0.65 }

Observations of one source kind do not corroborate each other — the strongest counts — while independent kinds combine as 1 - (1 - a)(1 - b): a pod read from code (0.3) and seen running (0.9) is 0.93. A resource with no evidence takes its provenance.confidence, or evidence.unevidenced (0.5). The default ontology ships the source kinds code, manifest, config, infrastructure, api, runtime, docs, conversation and human, and rules for the types where it matters; an ontology written before evidence existed gets the same defaults. docket ontology show <type> prints the confidence each source earns for that type, and docket validate rejects evidence that does not give the location its source requires.

Several files may declare the same id — two branches capturing the same service, say. They are one resource: projections receive a single merged entity with every file's evidence, one relationship per (source, rel, target), and the confidence the combined evidence earns. The first file by path names it and its attribute values stand; a file that disagrees gets a warning, and one that gives the id another type is left out with an error.

How it fits together

ontology + canonical files
          │ authoritative
          ▼
    reconciliation
          ▼
      projections
   disposable / rebuildable

The filesystem is the durable memory protocol. The ontology defines what structured knowledge means. The watcher turns file changes into normalized resource changes. Projections provide query capability, and nothing in them is a source of truth.

Links may point at resources that do not exist yet — the graph is expected to be built incrementally, so a dangling reference is a warning until you ask for --strict.

Projections

The Markdown files are the source. A projection is a destination: sync and watch push every change into each adapter .docket.yaml enables for projection, and docket search asks each one enabled for query.

.docket/**/*.md ──► docket sync / watch ──┬──► jsonl  → .docket/.index/*.jsonl
                                          ├──► mem0   → hosted or self-hosted mem0
                                          └──► neo4j  → a Neo4j graph

An adapter declares which canonical inputs it takes, and is handed only those (docs/adapter-spec.md §8):

  • entity — one per id, however many files declare it, each resource and link carrying its evidence, evidence count, corroborating source kinds and confidence. Its id and revision (the merged hash) are what they always were. The bundled jsonl, mem0 and neo4j adapters take entities only.
  • observation — one per distinct evidence record, of a resource or of one of its links: what was seen, its source locations (path and lines), the canonical files that record it, and observedAt only when the record states one. Canonical evidence records no event time, so none is given; a file's modification time is never used as one. Its id is its entity's id and a hash of the record, so a corrected record replaces the old observation.
  • document — one per canonical file with a Markdown body: the text, the entities it declares and [[mentions]], and the span of the file it is, startLine to endLine at the file's hash.

Configuring adapters

.docket.yaml version 2 lists adapter instances (docs/adapter-spec.md §5):

version: 2

adapters:
  - id: local                        # unique; names the instance in answers and errors
    module: "@docket/adapter-jsonl"   # a package, or ./a/file.mjs relative to .docket.yaml
    roles: [projection, query]       # default: both; a role left out never runs
    config:                          # the adapter's own settings
      output: .docket/.index

  - id: enterprise-graph
    module: "@docket/adapter-neo4j"
    roles: [query]                   # asked questions, never synced into
    config:
      uri: "neo4j+s://graph.internal.example"
      database: project-memory
      username: docket
      passwordEnv: DOCKET_GRAPH_PASSWORD
      scope: payments-project

  - id: company-memory
    module: "./tools/docket/company-memory.mjs"
    config:
      endpoint: "https://memory.internal.example"
      tokenEnv: COMPANY_MEMORY_TOKEN

query:
  defaultAdapters: [local, enterprise-graph, company-memory]   # default: every query adapter
  timeoutMs: 30000
  maxConcurrentAdapters: 3
  synthesis: true

An instance's id is its identity, separate from its module, so one package can serve several instances — two Neo4j databases, say. docket itself checks only this envelope, strictly: an unknown field, a repeated id, a role other than projection or query, a runtime that is not defined or a default adapter that is not configured for query is an error naming the instance. Everything under config belongs to the adapter, which receives it as written and validates it when the docket opens; the bundled adapters reject a field they do not know rather than ignore it, and their errors name the instance too.

Secrets never go in the file. An adapter takes the name of the environment variable holding one — passwordEnv, apiKeyEnv, tokenEnv — and reads the value itself; docket never writes a value into a manifest, error, log or API response.

docket ask, docket search and the UI ask the instances query.defaultAdapters names, or every instance with the query role, unless a question names its own. timeoutMs is the deadline every adapter answers within, maxConcurrentAdapters how many answer at once, and synthesis whether a summary is written by default (docs/adapter-spec.md §10). docket init writes a version 2 file.

Set up a provider: docket adapter add

docket adapter add <provider> replaces the manual steps for the bundled adapters. It asks what the adapter needs, shows the resolved plan - the .docket.yaml entry, any runtime group and Compose file, the environment variables to set, the driver package and the commands to run next - and applies it only once you confirm:

docket adapter add neo4j    # an existing or hosted server, or a local container
docket adapter add mem0     # hosted mem0 (app.mem0.ai) or a self-hosted mem0 server
docket adapter add jsonl    # another JSONL index
docket adapter add memvid   # a memvid .mv2 file, searched through the memvid CLI
docket adapter add mempalace  # drawers in a MemPalace wing, through its MCP server
docket adapters list        # what is configured, and whether each module and driver loads

For Neo4j, choose an existing endpoint (its URI, username and the name of the variable holding its password) or a local container. A local container gets a runtime group in .docket.yaml and a Compose template you then own, ./infra/docket-<runtime>.compose.yaml, binding Bolt to 127.0.0.1. Its image is the reference you give with --image (an internal registry tag or digest), or, without one, a required ${DOCKET_NEO4J_IMAGE} Compose reads when the runtime starts: docket never picks an image. For mem0, choose hosted (an API key variable, MEM0_API_KEY by default) or a self-hosted server URL. mem0's in-process oss mode needs an embedder, vector store and LLM, so write that entry by hand.

memvid and MemPalace are embedded: no secrets and no runtime group, only an optional file or palace directory, namespace or wing, and the command docket runs. Their adapter packages are not bundled with the CLI and not published to npm, so the plan reminds you to add the package to the project (a workspace or file: dependency), along with the engine each drives and docket never installs: npm install -g memvid-cli@2.0.160, or pip install mempalace==3.10.0 and its embedding model, downloaded beforehand.

What it will and will not do:

  • It never overwrites a file. An existing Compose file at the chosen path is kept as it is, and the plan says so.
  • It never asks for, shows or writes a secret: only the name of the environment variable holding one. A value given where a name belongs, or credentials in a URL, is refused.
  • It writes version 2 entries. In a version 1 .docket.yaml it offers to run docket config migrate first (keeping the original as a backup) and otherwise explains how; it never migrates silently.
  • It installs the driver (neo4j-driver or mem0ai) only with your consent, with the project's package manager (from packageManager or its lockfile): asked at a terminal, or --install.
  • It never pulls an image or starts a container. It prints docket runtime up <id> for you to run, and can run docket runtime plan <id> (which starts nothing) when asked (--plan-runtime). For an endpoint it can check the connection (--check), opening the adapter as docket sync would.

Scripts and CI pass the answers as options and --yes to apply without asking; a required answer that is missing is an error naming its option, and an option that does not apply to what was chosen is refused. --dry-run prints the plan and changes nothing.

docket adapter add neo4j --mode local --image registry.internal/neo4j:5.26 --yes
docket adapter add neo4j --url neo4j+s://graph.internal.example --password-env DOCKET_GRAPH_PASSWORD --yes
docket adapter add mem0 --mode platform --api-key-env MEM0_API_KEY --install --yes
docket adapter add mem0 --mode server --url http://localhost:8888 --dry-run

docket adapter add --help lists every option. A project-local adapter module is added to .docket.yaml by hand (Writing an adapter).

Version 1 files

A version 1 .docket.yaml keeps working unchanged. Its projections are read as adapter instances: each entry's type picks the module and names the instance (jsonl, mem0, then mem0#2 for a second of a type; type: file still means jsonl), its runtime moves to the instance, and every other field becomes the instance's config — every mem0 mode, Neo4j setting and scope included. Both roles are enabled, as before.

docket config migrate --dry-run prints the file rewritten as version 2. docket config migrate shows it and asks before writing; --write writes without asking, which is also the only way it writes without a terminal. Either way it first copies the original to .docket.yaml.v1.bak (or .v1.bak.2, never over an existing backup). Only version and projections change, and comments stay, though comments inside the old entries keep their version 1 wording. The rewritten file loads as exactly the same configuration, so the next sync reprojects nothing.

The bundled adapters

jsonl (@docket/adapter-jsonl) writes documents.jsonl, nodes.jsonl and edges.jsonl under output (default .docket/.index) — a readable view of exactly what projections receive. Output is deterministic, so rebuilds are byte-identical and diffable. It is the default, and the only adapter docket ask has out of the box: a lexical keyword search over titles, ids, tags and bodies, answering with each matching entity and the paragraphs of it that matched, at the revision it was projected.

mem0 (@docket/adapter-mem0) stores each resource as one verbatim memory (infer: false): the title, type, id, confidence, body, links, evidence and tags as text; the id, type, paths, hash, tags, confidence and evidence count as metadata. Rebuilds reproduce it exactly and cost no LLM calls. Documents with index.vector: false are left out. It needs the optional mem0ai package (pnpm add mem0ai).

adapters:
  # Hosted mem0. The key comes from the environment, never the file.
  - id: memories
    module: "@docket/adapter-mem0"
    config:
      mode: platform
      apiKeyEnv: MEM0_API_KEY   # default
      # host: https://api.mem0.ai

  # mem0's self-hosted REST server.
  - id: team-memories
    module: "@docket/adapter-mem0"
    config:
      mode: server
      url: http://localhost:8888
      apiKeyEnv: MEM0_API_KEY   # optional: the server can run without auth

  # Or mem0ai/oss in-process. Its `config` goes to mem0's `Memory` constructor
  # untouched, so any embedder, vector store or LLM mem0 supports works.
  - id: local-memories
    module: "@docket/adapter-mem0"
    config:
      mode: oss
      config:
        embedder: { provider: ollama, config: { model: nomic-embed-text } }
        vectorStore: { provider: qdrant, config: { host: localhost, port: 6333 } }
        llm: { provider: ollama, config: { model: "qwen2.5:7b" } }

Every mode also takes minScore (drop hits below that similarity) and scope. All modes file memories under one scope, by default agentId: docket-<checkout directory>-<hash>, where the hash is taken over the checkout's absolute path (symlinks and letter case resolved). Two clones or worktrees with the same directory name therefore never share a scope. Moving a checkout to a new path starts a new scope and leaves the old remote one behind. The path is the only input, so checkouts on different machines at the same path (devcontainers, Codespaces, CI runners) get the same default scope: if they point at one shared mem0 or Neo4j server, each must set scope: (userId, agentId and/or runId) explicitly. Set scope: too to choose your own. docket rebuild deletes and repopulates the whole scope, so do not share it with memories written by anything else. The neo4j adapter's scope defaults the same way.

Scopes created by earlier versions (team-memory-<directory> in mem0, the bare directory name in Neo4j) are left behind, not migrated or deleted: the first docket sync projects everything into the new scope. Delete the old scope by hand if you no longer want it, or set scope: to the old value to keep using it. Set MEM0_TELEMETRY=false to turn off the mem0 SDK's telemetry.

neo4j (@docket/adapter-neo4j) writes one (:Memory:<Type>) node per resource and one relationship per (source, rel, target), with confidence, evidenceCount, sources and evidence on both, so a query can ask for what rests on code alone — and a full-text index over the documents. It needs the optional neo4j-driver package and a running Neo4j server, local or hosted. A question is answered with a full-text query, or, with cypher set, a local Ollama model writes a read-only Cypher query against the graph's schema. The query must bind every pattern it matches to the instance's scope or it is not run; its rows are kept as the values they are - a single row of numbers as metrics, anything else as a typed table, relationships as a graph - each row cited as a derived fact. It counts as exhaustive only when it ran to completion; full-text, or a cypher query that fails or finds nothing, answers with the best matches instead.

adapters:
  - id: graph
    module: "@docket/adapter-neo4j"
    config:
      uri: bolt://localhost:7687        # default; any driver scheme, e.g. neo4j+s://
      username: neo4j                   # default
      passwordEnv: NEO4J_PASSWORD       # unset: connect without auth
      # database: neo4j
      # scope: payments-project
      # cypher:
      #   model: "qwen2.5:7b"           # Ollama at http://localhost:11434

uri is also accepted as url, its name in version 1 entries.

Sync manifests

Each adapter instance enabled for projection has its own manifest, <state.dir>/manifests/<id>.json (default .docket/.index), recording the revision of every input it acknowledged and the files behind each entity. Sync plans each instance against its own manifest, so an input is handed again whenever its revision changes — one of its files, or a confidence rule in the ontology — and an instance that is behind catches up on its own.

  • Failures stay with their instance. Instances sync concurrently and independently. A manifest is written only after its instance flushed, and only with what the instance's ApplyReceipt acknowledged: a change it failed, or left out of the receipt, keeps its previous entry and is retried (once more in the same pass when retryable, then on the next sync). An instance that cannot be reached, or throws, is reported and its manifest left as it was; every other instance still syncs. docket sync then exits non-zero, naming what failed.
  • Batches are not transactions. Changes are handed over in batches of up to 100, each carrying the checkpoint the instance is at once every change in it is acknowledged — a hash identifying exactly the records it then holds. Replaying a change an instance already applied must not duplicate it, which is how a pass that failed before its manifest was written recovers.
  • Configuration is fingerprinted. A manifest is tied to its instance's module, configuration (as the adapter read it) and scope. Changing an instance's endpoint, scope or any setting makes its next sync reset that instance's own namespace — never a shared store globally — and project everything into it; other instances are untouched. Adding an instance projects everything into it alone. Renaming an instance of a bundled adapter (jsonl, mem0, neo4j) keeps its manifest; renaming an instance of any other module starts it afresh, because its state directory moves with its id.
  • Upgrading reprojects nothing. Earlier versions kept one manifest, <state.dir>/manifest.json, for every projection together. When an instance has no manifest of its own and that file was written for exactly the projections configured now, the instance takes its entity records over and is not reset; input kinds it never received before are projected as new. The shared file is deleted once every configured instance has its own. If the projections changed in the same upgrade, the shared file vouches for none of them, and each instance is reset and reprojected as on a first sync.

Writing an adapter

New engines plug in as memory adapters, through the contracts in packages/contracts (@docket/contracts, not published on its own; its code ships bundled inside @chrisjowen/docket, which also exports its types). The three adapters above are packages too, each with its own configuration and driver dependency, sharing @docket/adapter-kit. docket's core imports none of them: an instance's module is loaded only when it is configured.

The standard @chrisjowen/docket package ships all three, bundled in dist/bundled. The drivers stay optional: install neo4j-driver or mem0ai next to docket only for the adapter you configure. A jsonl-only project needs neither, nor Docker, Python or network access. A project's own install of an adapter package (from a private registry or a workspace) is used in preference to the bundled copy. For a minimal core, build docket with only the adapters you want bundled — DOCKET_BUNDLED_ADAPTERS=jsonl pnpm build, or empty for none. The @docket/adapter-* packages are not published to npm, so a project using a minimal core cannot npm install the adapters it left out: a configured adapter that is neither installed nor bundled fails with an error saying it is not included in this build, and that a build with DOCKET_BUNDLED_ADAPTERS unset or listing it includes it.

A project-local adapter is a compiled .js or .mjs module whose default export is an AdapterDefinition (docs/adapter-spec.md §7, §12), named by its path in module. A program can also open a docket with more adapters than the file lists:

import { createDocket } from '@chrisjowen/docket'

const docket = await createDocket({
  projectRoot,
  // A compiled module relative to .docket.yaml, or a package installed in the project.
  adapters: [{ id: 'company-memory', module: './tools/docket/company-memory.mjs', config }],
  // Or a definition passed in directly.
  registrations: [{ id: 'other-memory', definition, config }]
})

docket never installs an adapter package, and runs a .ts adapter only with a TypeScript runner passed as typescript.

Two recall engines are adapter packages loaded this way, not bundled with the CLI: @docket/adapter-memvid (a memvid .mv2 file, searched lexically through the memvid CLI) and @docket/adapter-mempalace (drawers in a MemPalace wing, recalled through its MCP server). Each answers with source passages and the canonical records they came from; their READMEs list the engine versions they were tested against, what each needs installed, and what each does not do.

Local runtimes

An adapter connects to a service; docket does not start one unless asked. Hosted and already-running services need nothing more, and no command but docket runtime ever calls Docker - not sync, watch, search, open or loading an adapter, even with a runtimes section configured.

To have docket manage local containers explicitly, write your own Compose file with the images you approve, and name it in a runtimes group - or have docket adapter add neo4j --mode local write both from the image you give (Set up a provider). An adapter instance's runtime: says which group it connects to (in a version 1 file, a projection's runtime: does):

adapters:
  - id: dev-graph
    module: "@docket/adapter-neo4j"
    runtime: graph-dev
    config:
      uri: bolt://127.0.0.1:17687
      passwordEnv: DOCKET_GRAPH_PASSWORD

runtimes:
  graph-dev:
    provider: docker-compose
    composeFile: ./infra/docket-memory.compose.yaml   # relative to .docket.yaml
    projectName: docket-payments
    pullPolicy: never        # never (default) | missing | always
    services: [graph]        # default: every service in the file
# infra/docket-memory.compose.yaml - yours; docket never rewrites it
services:
  graph:
    image: ${DOCKET_NEO4J_IMAGE:?Set the approved image reference}
    ports:
      - "127.0.0.1:17687:7687"
    environment:
      NEO4J_AUTH: ${DOCKET_NEO4J_AUTH:?Set the local authentication value}
    volumes:
      - graph-data:/data
volumes:
  graph-data: {}

docket has no images of its own: the image is exactly what the Compose file resolves, an internal registry tag or digest included, and registry logins are Docker's own. Commands, entrypoints, health checks, networks and extra services all go in the Compose file.

  • docket runtime plan <id> checks the group and the Compose file (through docker compose config, which starts nothing) and prints each service's image, ports, volumes and environment variable names - never their values - with the exact commands up, status and down run.
  • docket runtime up <id> runs docker compose up --detach --pull <pullPolicy> --no-build: Compose enforces the pull policy, and a Compose too old to support it fails rather than pulling. docket never builds images.
  • docket runtime status <id> lists the project's containers by Compose label (docker ps), so it changes nothing and needs none of the Compose file's variables set.
  • docket runtime down <id> runs docker compose --project-name <projectName> down without the Compose file, so it too needs none of the file's variables set, and keeps named volumes. Only --destroy-volumes adds --volumes; docket rebuild never touches containers or volumes.

Claude Code plugin

packages/claude-plugin/ contains skills and hooks that let Claude capture durable project knowledge by editing the same canonical files a human would. Agents never write to an index. See its README.

Development

pnpm install
pnpm typecheck   # sources and tests
pnpm build       # the adapter contracts, kit and adapters and the web UI
                 # first, then docket, which ships copies of them; the
                 # end-to-end tests run the built CLI, so build first
pnpm test        # the contracts', adapters', CLI's and plugin's suites

To work on the web UI with hot reload, run docket open --no-open in a repository with a .docket/, then pnpm -C packages/docket-ui dev; the dev server proxies /api to it (set DOCKET_API if it is not on port 4380).

Layout, phases and track ownership are in PLAN.md. The full specification is docs/SPEC.md.

Releasing

.github/workflows/publish.yml stages @chrisjowen/docket on npm, with provenance, when a v<version> tag is pushed (or a GitHub release creates one). It typechecks, builds and tests first, and refuses a tag that does not match packages/docket/package.json. npm does not allow publishing from CI without 2FA, so the workflow runs npm stage publish: the version lands in npm's staging area and goes live only once a maintainer approves it.

  1. Bump version in packages/docket/package.json, and CLI_VERSION in packages/claude-plugin/scripts/cli.js to match (the plugin's tests fail until they agree), and merge it.
  2. Tag that commit v<version> and push the tag, or publish a GitHub release for it.
  3. Approve the staged version with 2FA: npm stage approve <stage-id>, or the Staged Packages tab on npmjs.com.

The workflow authenticates with the NPM_TOKEN repository secret (an npm token with publish rights to @chrisjowen/docket, already configured) and signs provenance through GitHub's OIDC token. The first stage of a new package also creates a public 0.0.0-stage placeholder version on npm.

Check what a release will contain with npm pack --dry-run in packages/docket.


The case-file illustration at the top, docs/assets/docket-hero.svg, is original artwork drawn for this repository; it uses no third-party images. The docket open screenshot shows the examples/acme-platform case file.

About

Local-first team memory for software repositories. Markdown files are canonical; indexes are disposable projections.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages