Project memory

What the team has learned about a project — decisions, constraints, contracts, gotchas — read by every teammate's sessions automatically.

A project is a set of repositories. A fleet manager, an integration service and the code around them are one project, worked on by people who do not all have the same repos cloned.

Set one up

$ ptln project init acr --remote git@github.com:acme/acr-memory.git
ptln project add-repo acr          # run inside each member repo

A teammate joins with the memory repo's URL:

$ ptln project join git@github.com:acme/acr-memory.git
ptln project add-repo acr          # inside the repos they have

add-repo writes .partyline.json in the repo. Commit it: anyone who clones the repo inherits the link.

A repo can also belong to a project without carrying that file. The project records its members by canonical git remote, so this machine can match a directory to its project by asking git what the repo's origin is — useful when writing into a repo is not yours to do. The checked-in file stays the way a teammate inherits the link automatically; it is no longer the only way.

An ssh host alias (github-acr:acr-retail/x.git) names the same repository as git@github.com:acr-retail/x.git, and both resolve to one identity, so a repo spelled one way on your machine and another way on a teammate's is still one repo.

Record and read

$ ptln memory add decision "Fleet talks to integration over gRPC — the POS links are flaky" --tags transport
ptln memory brief
ptln memory ls [--all]
ptln memory sync
KindFor
decisiona choice and why
constraintsomething the code must respect
contractan interface others depend on
gotchaa trap that cost time
questionopen, needs a human

Agents do this themselves through two MCP tools: project_memory reads, remember records. That is the intended path — the CLI is for inspecting and repairing.

It records by itself

Nobody types "save this to memory". A system that depends on them doing so stays empty, so the human act here is rejecting a fact that is wrong, not saving one that is right — rejection is rare and motivated, saving is frequent and unmotivated.

That is only safe because two things are separate:

  • Capture is automatic and generous. Sessions write what they learned.
  • The brief is capped at 12 and is a ranked view over what was captured.

Capturing more can never flood what people read.

At the end of a turn, a session distils what it just did into at most two facts and records them as proposals. It runs detached, so it never delays anything, and it is throttled to once every 20 minutes per session. Most turns produce nothing, which is the intended result — the distiller is told to prefer recording nothing and that a session which teaches nobody anything is the normal case.

Captured facts are marked by: harvest, cite the session they came from, and are briefed below facts a person recorded.

SettingEffect
PARTYLINE_MEMORY_CAPTURE=offno automatic capture; everything else is unchanged

Commits are facts

A commit message is a decision, written by the person who made it, at the moment they made it. Re-typing it into remember afterwards is doing the work twice, and the second time does not happen. So a commit can record a fact directly, with a trailer:

fix the POS callback handling It retries without jitter, which we found the hard way. Ptln-Gotcha: The POS callback retries 3x with no jitter, so anything downstream has to be idempotent.

One trailer per kind — Ptln-Decision, Ptln-Constraint, Ptln-Contract, Ptln-Gotcha, Ptln-Question. Indented lines continue the previous trailer, so a fact can be a sentence. An unknown kind is ignored rather than invented: the set of five stays closed.

$ ptln memory harvest          # record what this repo's new commits declare
ptln memory harvest -n       # show what would be recorded, write nothing

Ordinary commits produce nothing. A commit opts in by carrying a trailer, the same way it opts into Signed-off-by. That is deliberate: the brief is capped at 12 facts and the cap is the only reason it gets read, so a harvester that guessed would fill it with sediment.

The fact records the commit it came from, which is what makes it checkable rather than merely asserted — a teammate with that repo can read the diff. Its id is derived from the commit hash, so harvesting twice, or on two machines that both have the repo, writes the same file rather than a duplicate.

Harvesting runs on the machine that has the clone and publishes into the shared memory repo. A teammate's commit messages reach you; their code does not.

Backfilling from what you already have

A team's decisions are already recorded somewhere — a ticket tracker, old commits, session logs, chat. The problem is not access, it is that those sources are mostly noise and the brief only holds 12 facts.

partyline does not read those systems. An agent already can, through whatever MCP servers you have connected, and judging what matters is the part an LLM is good at. So the agent gathers and distills, and partyline takes the result:

$ ptln memory propose --from odoo:ACR-1412 --from commit:4a50eb9 gotcha "The POS callback retries 3x with no jitter"

Agents call propose_fact for the same thing. Any source an agent can reach is a backfill source; there is no connector to write.

Every proposal must cite its sources. A citation is <system>:<id> and at least one is required. This is not bookkeeping: it changes review from "is this true?" — which is slow and needs the reviewer to already know the answer — to "does this match what it cites?", which takes seconds. Review throughput is the real limit on backfilling, so a proposal that cannot be checked is refused rather than recorded weakly.

Proposed facts are marked and ranked lower. They record by: harvest with their citations, and are briefed below facts a person wrote. A fact inferred from a two-year-old ticket is weaker evidence than one recorded by someone who was there; if the two look identical, the memory's credibility settles at the level of its weakest entry.

Duplicates are refused, so backfilling twice, or from two sources that say the same thing, does not double-record.

What to mine, and what to leave

PreferBecause
Something corroborated by two independent sourcesA decision in a commit and a ticket is real; one mentioned once in a session log is usually chatter
First-hand sources — commits, tickets someone wroteA generated session summary is two lossy steps from the work
Reverts, and commits with long bodiesA revert means someone got burned; a long body means someone stopped to explain
Anything already curated — CLAUDE.md, ADRsHand-written facts, just trapped in one repo where teammates cannot see them

Leave transient state where it is: build results, test failures since fixed, work in progress. The test is whether it will still be true in a month, and whether a teammate who is not you would care.

Run the first backfill as a calibration: one repo, then look at what fraction you would keep. Accepting most of it means the filters are right and it is worth doing across every repo. Accepting little means running it everywhere would spend your review time for not much.

How it travels

Memory is one file per fact in the project's memory repository, synced by git. One file per fact means two people writing at the same time never conflict. No account and no server are involved, it works offline, and a teammate reads facts about repositories they have never cloned.

What a session is told

Session start reads the memory and shows what matters here: this repo's facts first, then the project's, open questions above settled ones, capped at 12 so it stays read. Facts that disagree — same subject, different people, neither superseding the other — are shown as a conflict naming both authors.

A fact that replaces an older one carries --supersedes <id>. The replaced fact stops appearing and stays in history (--all).

How fast it arrives

Three layers, each a fallback for the one above it:

LayerCarries a changeNeeds
Local writeimmediatelynothing
Watcherwithin 45 secondsgit access to the memory repo
Busabout a seconda reachable partyline instance

The bus is a WebSocket between each machine and the instance. When a fact is pushed, the writer announces it; every teammate's machine fetches on that announcement instead of waiting out the interval.

It carries news, not content: an announcement says only that a project's memory moved, and the receiver still reads the facts from git. That is what makes it safe to lose. With the bus down, unreachable or never configured, everything still works on the 45-second path — a message that never arrives costs latency, not a fact.

$ ptln bus listen                    # watch events for this repo's project
ptln bus send "deploying now"      # put a message on the channel

Both are diagnostics. The watcher and the agents are the real users.

Who else is here

When the bus is up, the ribbon shows how many other people are working on the project:

☎ acr · fleet-manager  14 learned  ● 2 today  ◉ 1 other here

People, not connections — one person with a laptop and a server counts once. It is read from a file the watcher maintains, never from the network, because a status bar that waits on a socket stalls every redraw.

Presence is decoration, and it degrades to silence: with the bus down, the watcher stopped, or the roster stale, the ribbon says nothing rather than naming someone who is not there. A teammate who does not appear may still be working.

SettingEffect
PARTYLINE_BUSthe bus URL (default: this instance's /bus)
PARTYLINE_BUS_PROJECTthe project to subscribe to, for repos not bound to one

Long-running sessions

Sessions do not end. A session started on Monday is briefed on Monday, so it would never learn what a teammate recorded on Thursday — and capture "at session end" would never run.

The refresh is per turn, not per session. Every claude session partyline launches inside a project carries a UserPromptSubmit hook that prints what is new since that session last looked:

☎ acr — new since this session was briefed:
- [gotcha · integration] The POS callback retries 3x with no jitter (Matt, 20260917-223917-4a50eb)
Treat these as current. If one contradicts what you were told earlier, the newer fact wins.

It prints nothing on the turns where nothing changed, which is nearly all of them. At most 5 facts land at once; the rest are one ptln memory ls away.

SettingEffect
PARTYLINE_MEMORY_REFRESH=offno mid-session refresh; the session-start brief still applies

Engines other than claude have no hook to carry this yet: their sessions get the brief they started with, and ptln memory brief on demand.