Skip to content

Per-Repo Tenancy

What coding learns in a repo belongs to that repo: it is kept next to it in .coding/, optionally versioned in a private <repo>-history repo, and shared with teammates by git. Teams are sets of repos, and they decide what the viewer shows and what an agent is told.

The one idea

The repo is the unit of persistence and sharing. Each repo X you work in gets a folder X/.coding/ that holds everything learned there — session logs, observations, digests, insights, the knowledge-graph slice, token-usage measurements. That folder is its own small git repo (X-history), private, and ignored by X itself.

Where learned data lives

First launch in a repo

The first coding in a repo asks once where its learned data should go:

First-launch prompt

You answer What happens
Enter a private bmw.ghe.com/<you>/X-history is created (or reused) and checked out at X/.coding/
a teammate's URL their X-history is cloned — from now on you share what is learned in X
skip X/.coding/ stays local and untracked; never asked again

The answer is kept in ~/.coding/repos.yaml.

Sharing

coding sync              # what each learning repo has to push / pull
coding sync --push       # asks, then pushes — nothing else ever pushes
coding sync pull         # what every launch does in the background

Commits are automatic (session end, every 30 minutes); pushing is always your call.

Teams

A team is a named set of repos. Pick teams in Dashboard → Teams; the knowledge viewer follows that selection. Agents are scoped by the teams of the repo they run in — knowledge from another team is never injected.

Two kinds of data

Kept per repo (X/.coding/, shareable) Kept per machine (~/.coding/data/<scope>/var/, never shared)
history/ — live session logs (LSL, .jsonl) the live knowledge store (LevelDB, owned by obs-api)
kb/knowledge-graph/X.json — entities + relations of project X the Qdrant vectors used for injection
kb/observation-export/ — observations, digests, insights of X the proxy's token DB, raw measurements, run snapshots
kb/usage/<user_hash>.json — token usage per day and per task discovery cache, sync state, shared clones

The knowledge on the per-machine side is a cache: a fresh machine hydrates its store from every .coding/kb/ it can see and re-embeds it. The per-repo side is what travels.

There is no X/.specstory/ any more, and nothing addresses the old path. X/.coding/ is excluded through X/.git/info/exclude — no change to X's tracked .gitignore.

What happened to .specstory/

Was Is now
X/.specstory/history/ (session logs, logs/) X/.coding/history/ — an old real directory is migrated on the next launch; an old .specstory/history symlink is removed
.specstory/config/redaction-patterns.json, sensitivity topics config/redaction/ in the coding tools repo — shared by the ETM, the obs-api writers and the LLM proxy's raw-body redaction
.specstory/config/knowledge-system.json config/knowledge-system.json in the tools repo
.specstory/trajectory/ deleted — nothing read it

Machine-local leftovers (LSL archive, validator report) live in X/.coding/var/, which is git-ignored.

How a repo gets its learning repo

Learning repo setup

  • A public remote is refused: learned data contains prompts and code.
  • An old layout (a real .specstory/history directory, with or without its own git checkout) is migrated on the next launch without a question: it moves to X/.coding/history/, an existing remote is recorded, and .specstory/ is not recreated.
  • Unattended launches use LSL_HISTORY_AUTO=yes|no; with nobody to ask, only the local layout is created and the question waits for the next interactive launch.

How sharing works

Sharing learned data

Git is the only transport, and it carries files, not database state:

  1. obs-api exports each project's slice of the live store to that repo's kb/knowledge-graph/X.json — sorted, and only rewritten when it changed, so two machines with the same knowledge produce byte-identical files.
  2. Local commits happen at session end and every 30 minutes. coding sync --push is the only push.
  3. A teammate's launch pulls. A git merge driver merges concurrent edits of the same graph file by entity — newest updatedAt wins, relations by (from, type, to), deletions travel as tombstones — so there are no text conflicts in JSON.
  4. obs-api merges what arrived into the live store, embeds the new entities and drops the vectors of deleted ones. The next prompt can be answered from it.

A repo you do not have checked out can still be read: list its X-history remote under a team's repos: and it is cloned (pull-only) under var/shared/X/.

Teams and filtering

Team filter scoping

Entities record the repo they were learned in (metadata.project). Team membership is looked up when reading, never written into the graph — so a repo can sit in several teams and move between them freely.

Where Which teams apply
Knowledge viewer the active selection (Dashboard → Teams, or the viewer's Teams rail — kept in sync both ways)
obs-api ?teams=a,b the teams named in the request
Knowledge injection the teams the session's repo belongs to; a repo in no team sees only its own project

The active selection is what you look at; it deliberately does not change what an agent working in some repo is told.

Measurements

The LLM proxy stores the project of every call (x-project header, set by the launcher from the repo). obs-api writes this machine's rows for each repo hourly to X/.coding/kb/usage/<user_hash>.json — one file per user, so teammates never conflict.

See Teams & Shared Learning for the dashboard and viewer side.

Decisions behind the design

# Decision
D1 Learned data lives in a nested checkout <repo>/.coding/ (the <repo>-history repo) with history/ and kb/. There is no <repo>/.specstory/ (T9 dropped the compatibility symlink; launch removes an old one).
D3 Pull automatically at session start, commit locally automatically, push only on confirmation.
D6 One live store per machine — obs-api is the single owner of km-core's LevelDB, one Qdrant. The repo is the unit of persistence and sharing; a team is a set of repos; every filter maps repo → team. No per-team or per-repo live stores.
D7 The scope (~/.coding/scope) names only this machine's runtime home ~/.coding/data/<scope>/. It is not what is persisted or shared.

Files

Path Written by Holds
~/.coding/repos.yaml launcher (lib/history/repo-link.mjs) repo path → { remote } \| { skip: true }
~/.coding/teams.yaml Dashboard → Teams (lib/teams/config.cjs writeUserTeams) your teams, memberships, active:
~/.coding/features.yaml install.sh, coding-features, Dashboard → Features the installed tier / feature switches
<repo>/.coding/history/<yyyy>/<mm>/*.jsonl the session logger (ETM) live session logs
<repo>/.coding/kb/knowledge-graph/<project>.json obs-api only (lib/kb/layout.mjs, mode owner) the project's entities, relations, tombstones
<repo>/.coding/kb/observation-export/*.json obs-api (ObservationExporter) per-project slice of the observation cold archive, merged by row id
<repo>/.coding/kb/usage/<user_hash>.json obs-api hourly (lib/usage/project-usage.mjs) this user's token usage for the project, daily + per task
<data home>/var/projects.json lib/teams/discover.mjs discovered repos (TTL 10 min)
<data home>/var/sync-state.json coordinator, coding sync unpushed commits per checkout (the status line's [P:n])
<data home>/var/shared/<X>/ lib/teams/shared.mjs pull-only clones of teammates' learning repos

A project with no checkout on this machine is exported to <data home>/kb/knowledge-graph/exports/projects/<project>.json. The container's stores (semantic-analysis) read every file but write only exports/general.json (mode local) — /workspace is mounted read-only.

The launch step

scripts/agent-common-setup.sh → ensure_private_history_repo → node lib/history/repo-link.mjs ensure <repo>, then pull_learning_repo (background).

Remote state (remoteState) Action
public refused
populated cloneInto — tracked files win, untracked local files are kept
absent gh repo create --private (or git init --bare for a local path), push the skeleton
empty init + push the skeleton
unknown (no gh, ls-remote failed) clone attempted, else init

Environment: LSL_HISTORY_AUTO=yes|no, LSL_HISTORY_REMOTE_TEMPLATE (with {project}). The default remote is https://bmw.ghe.com/<gh user>/<repo>-history.git (gh's hosts.yml), else derived from the outer repo's bmw.ghe.com origin.

Hydrate is a union merge

lib/km-core/src/store/merge.ts, used at startup, on POST /api/kb/reload, by the git merge driver (lib/kb/merge-driver.mjs) and by the embedding backfill:

  • entities by id — newest updatedAt wins (ties: the live graph);
  • relations by (from, type, to) — graphology edge keys collide across machines;
  • deletions as tombstones in attributes.kmTombstones, kept 90 days — a tombstone beats every copy not edited after it;
  • a relation to an entity this machine does not have is kept aside and written back to its file, so it is not deleted for the teammate who has both ends.

Files are canonical (sorted) and rewritten only when their content changed. A file that changed under the store (a pull) is merged in before the next write, never overwritten.

Vectors

Embeddings are made on write. What arrives by git is embedded by the backfill obs-api runs 60 s after startup and after every reload that changed the graph (lib/kb/embed-index.mjs → dist/embedding/backfill.js, idempotent over content hash and preview version). The same pass deletes the kg_entities points of tombstoned entities by exact id (src/embedding/tombstones.ts). Injection is hybrid — Qdrant plus a keyword search over the live store — so a pulled insight is findable even before it is embedded.

Team configuration

Layer File Holds
1 config/teams/<id>.json ontology + validation (read directly by semantic-analysis)
2 config/teams.yaml shipped labels and kinds — no memberships
3 ~/.coding/teams.yaml yours: membership, own teams, active:
4 CODING_TEAMS=a,b env override of the selection
teams:
  raas:
    label: RaaS
    kind: team                                   # team | project
    repos:
      - ~/src/rapid-automations                  # a local checkout
      - https://bmw.ghe.com/me/x-history.git     # a learning repo, cloned when not here
      - { path: ~/src/x2, id: x }                # names the project id stamped there
    include: ['rapid-*']                         # directory-name globs
active: [raas]                                   # empty / absent = all teams
discovery:
  roots: ['~']
  depth: 4

A team with neither repos nor include contains the repo whose directory is named like the team — what metadata.team always meant.

Filtering

lib/teams/scope.mjs projectsOfTeams(teams) → project ids; matching is case-insensitive on metadata.project, then the legacy metadata.team.

  • obs-api — ?teams=a,b on /api/v1/entities, /api/coding/{observations,digests,insights} and /api/coding/lsl/sessions, applied before pagination. /api/teams returns each team's resolved projects and the active selection.
  • Viewer — one graph fetch, filtered client-side by both canvases, the rail counts and the History sidebar; the LSL strip asks the server. The Teams rail starts from the dashboard's active selection and writes changes back; it redraws only when the selection changed.
  • Injection — /api/retrieve takes teams / context.teams (hooks forward CODING_TEAMS), else the teams of the repo at context.cwd, else that repo's own project. Qdrant is queried with a project match.any filter on all four collections and every hit is checked again afterwards. Only a session outside every repo is unfiltered.

APIs

Endpoint Where Does
GET/PUT /teams, POST /teams/discover, POST /teams/sync coordinator :3034 team config, rescan, clone missing shared repos
/api/teams-config dashboard :3032 reverse proxy of the above
GET /api/teams obs-api :12436 teams with resolved projects (for the viewer)
GET /api/kb/layout obs-api which project is written to which file
POST /api/kb/reload obs-api merge changed files into the live store

Commands

coding sync                    # status of every learning checkout
coding sync pull --repo .      # what the launcher runs
coding sync commit             # local commit everywhere
coding sync --push [--yes]     # asks, then pushes
node lib/history/repo-link.mjs ensure <repo> [--remote <url>] [--no-ask]
curl -s localhost:12436/api/kb/layout | jq
curl -s -X POST localhost:12436/api/kb/reload