Repository navigation
Superself 0.13.0: a store can keep its records on a server, and the calculation that reads them is a package
Superself 0.13.0 gives a workspace store a second place to keep its records. Until now a store was a git repository this machine commits and pushes, and that was the only shape there was. A store can now keep its records on a workspace server this machine is signed in to: appends land in a queue the machine owns, the server's copy arrives back as its own file, and one read makes them one log. One store is one or the other for its whole life, and self init is where that is decided — with --git, with --cloud, or by asking. Underneath both, the fold — the calculation that turns an event log into the state a reader sees — is now @superself/fold, a published package, so a server rendering that state and this CLI answer from one calculation rather than from two implementations of it.
Nothing about a git-backed store changes. Every command below was run against the CLI built from this branch, and every text block is pasted from that run.
The fold is a package: @superself/fold
The state you read is never stored. It is calculated from the event log, every time — and until now that calculation lived inside the CLI. A Workspace API server rendering the same state from the same logs would have had to reimplement it, and two implementations of one fold are two answers to "what does this project owe".
The fold is now its own package. It takes an event array and returns a project's state, and it reaches for nothing else: no clock, no session, no git, no terminal. The four machine-local inputs the fold used to reach for itself are arguments now, in a second layer:
foldEvents(events, project) // what the log alone decides
applyLocalOverlay(model, events, local) // what one machine addsFor a reader of a store, that means the log is now readable by something that is not the CLI. Eighteen lines against the package, pointed at a store's own log.jsonl:
$ cat read-the-fold.mjs
// A reader that is not the CLI: no clock, no session, no git — just the log.
import { readFileSync } from "node:fs";
import { FOLD_VERSION, foldEvents } from "@superself/fold";
const events = readFileSync(process.argv[2], "utf8")
.trim().split("\n").map((line) => JSON.parse(line));
const model = foldEvents(events, { slug: "demo" });
console.log(`fold version ${FOLD_VERSION}, ${events.length} events`);
for (const unit of model.works)
{
console.log(`${unit.id} ${unit.status} ${unit.outcome} [${unit.criteria?.filter((c) => c.covered).length ?? 0} of ${unit.criteria?.length ?? 0} criteria covered]`);
}
for (const decision of model.decisions)
{
console.log(`decision: ${decision.text ?? decision.outcome}`);
}
$ node read-the-fold.mjs .../projects/demo/log.jsonl
fold version 1, 10 events
w-q42pa active the release notes are read off a real run [1 of 2 criteria covered]
decision: the fold ships as @superself/fold so a server answers as the CLI does
and the CLI, over the same log, saying the same thing about the same unit:
$ self work
w-q42pa active the release notes are read off a real run [toward o-3e2mm] [1 of 2 criteria covered] — 1 report(s), see `self work show w-q42pa --project 'demo'`
1 open work unit
FOLD_VERSION is the fold's own number, and it is what tells two readers their answers were produced by the same code. It is bumped when the fold's output changes for an unchanged log — a new reducer, a changed lifecycle rule, a new derived field. A refactor that moves the same calculation does not bump it, which is why this release, whose whole fold change was a move, ships FOLD_VERSION 1.
This changed nothing a reader sees, and that is the claim to check rather than take. It was checked: thirteen read commands were run against one git-backed store by the 0.13.0 CLI and by the 0.12.0 CLI, and every one of the twenty-six answers was byte-identical, exit code included. The list and the method are in the mixed-version section below.
A workspace store keeps its records in git or on a server
A store's records live in a git repository this machine commits, or on a workspace server this machine is signed in to. .superself/workspace.json is what says which: present means server-backed. Existence is the whole test, on purpose — a git-backed store has never written that file, so no store that exists today can be read as the new kind by accident.
A server-backed store keeps two files per project, both append-only:
projects/<slug>/log.jsonl the server's copy, arrival order
projects/<slug>/pending.jsonl this machine's queue of unsent appends
log.jsonl is written only by what comes back from the server. pending.jsonl records every change to itself as a line added — an append, a mark that one was sent, a mark that one may not be retried — so nothing is ever rewritten and no crash between two writes can lose a record.
That makes every event the property of exactly one of the two files: the queue owns it until the server has it, the copy owns it afterwards. There is no window in which a machine has written half a record. A store connected to a workspace and never yet synced holds a queue and no server copy at all — recording into one and then listing the store directory shows exactly that:
$ self goal add "the workspace holds what every machine recorded"
entity.confirmed recorded [01m1fztsvqdd3v6n35ft5yrwk2]
the workspace holds what every machine recorded — verify; wrong? self undo 01m1fztsvqdd3v6n35ft5yrwk2
$ self work add "a second machine reads what this one queued"
entity.confirmed recorded [01m1fztszx622qn2pxwgpz0tb4]
w-zdy4b a second machine reads what this one queued — verify; wrong? self undo 01m1fztszx622qn2pxwgpz0tb4
w-zdy4b
no objective yet? `self objective add "<outcome>" --proposed`, then `self objective confirm <id>`
$ ls .superself/projects/demo
pending.jsonl
state.md
work
No log.jsonl exists, because nothing has come back yet — and the store reads anyway, off the queue alone:
$ self work
w-zdy4b next a second machine reads what this one queued
1 open work unit
$ self status
demo — goal: the workspace holds what every machine recorded
work: 0 active, 0 blocked, 1 next, 0 done
objectives: 0 open, 0 of 0 milestones reached, 0 of 0 exit criteria covered
waiting on you: 0
unshipped: nothing waiting to ship
health: ok
No commits are added. The nine callers that say "the store has changed, record that" are unchanged and say what they always said; a server-backed store has already recorded it by queueing the append, so the commit step returns having done nothing. Counted across the two commands above, the store's git history stayed where it was:
before: commits=2 log.jsonl=absent pending.jsonl=absent
after: commits=2 log.jsonl=absent pending.jsonl=2
readEvents is where the two files become one log, and it is the one place they do: the server's copy first, in the order the server put it in, then this machine's unsent tail. A record the workspace has already agreed on stands ahead of one only this machine has made, and an append made offline lands after everything that arrived while it was offline. An append with a sent or a blocked mark against it is out of the read, and an event held by both files is counted once.
The verbs that stop meaning anything
Five verbs — store size, store compact, sync, remote add and clone — are about a git history a server-backed store does not have, and each is refused by name — naming the verb the caller ran and what it is for, rather than only what it is not:
$ self store size
error: `self store size` measures the git history a git-backed store keeps; this workspace store is server-backed and has none
$ self store compact
error: `self store compact` packs the git history a git-backed store keeps; this workspace store is server-backed and has none
$ self sync
error: `self sync` pushes a git-backed store to its git remote; this workspace store is server-backed and has none
$ self remote add https://example.com/store.git
error: `self remote add` points a git-backed store at a git remote; this workspace store is server-backed and has none
$ self clone https://example.com/store.git
error: `self clone` copies a git-backed store from a git remote; this workspace store is server-backed and has none
In a git-backed store beside it, store size answers as it always did:
$ self store size
store 208.4 KB — working tree 154.1 KB, .git 54.3 KB
artifacts: 1 recorded, 1 distinct contents, 28 B in 1 stored file
largest projects: demo 28 B
largest files: artifacts/demo/a-n2x56-note.md 28 B
orphan bytes: none — every stored file is named by a record
git objects: 96 loose (384.0 KB), pack 0 B — run `self store compact`
compacting packs the history; it never rewrites it, so bytes already in the history stay there
self alias, lang, theme, timezone and tokens write config.json, which is machine-local. In a server-backed store they take effect on this machine and stay there.
A server-backed store sends what it queued and receives what the workspace agreed
The queue above is emptied by a transport with a shape worth knowing before you use it:
catch up → the command → send
The catch-up cannot refuse anything. Being offline is an ordinary state rather than a failure, and the local files are a complete log of what this machine knows — so every path through the catch-up ends in the command running against what this machine holds. Pointed at a workspace that does not answer — here, an address nothing is listening on — a read command says nothing about it and answers from the queue:
$ SUPERSELF_API_BASE=https://127.0.0.1:9 SUPERSELF_SYNC=inline self work
w-zdy4b next a second machine reads what this one queued
1 open work unit
(exit 0)
The send cannot say anything. By default it is a process of its own, outliving the command that started it, so nobody waits on a network round trip. Having no terminal in front of it is exactly why a refusal it meets is written into the queue as a row and said by the next command that has somewhere to print, once.
Online, three rules decide what a reader can count on. A 200 writes nothing down: the mark that an append has gone is the pull's, written once it has seen every one of that append's event ids arrive in the server's copy — so between the push and that pull the append is sent again and the server absorbs it as a duplicate. That is the price of never having a record the queue has released and the copy does not hold. A batch the server refuses whole is re-sent one append at a time, splitting on append boundaries and nowhere else, so the append the server objects to is the only one that stops. And a 404 turns on the cached server project id and only on that: without one, this machine made the project and never told the server, so it says so once and pushes again; with one, the project existed and does not any more, and re-creating it would raise a project somebody deliberately removed.
One sync runs at a time, taken or not taken and never waited for. The foreground append stays outside that lock — one line added to a queue file is safe against another process doing the same, and recording work must not queue behind a network read. Staleness is a fact rather than an opinion: a sender gives itself three minutes and stops, a lock is taken from its holder at five, and every wait a sender can be made to do fits inside its own lease, so a live holder never reaches the age at which it is stolen from.
An event written into a server-backed store carries actor.account, stamped at the moment of the append, so authorship survives the account being switched on the machine before the record is sent. The value travels, not the reader: the account is read once at the entry point and left on the context, and the append path holds no way to ask for it.
self init chooses which kind of store it makes
Making a server-backed store is not a new verb. self init asks which kind to make, and the answer decides where every record the workspace ever holds is kept:
self init [--git|--cloud] [--workspace <id>] [--lang <code>] [--agents]$ self init --help
usage: self init [--git|--cloud] [--workspace <id>] [--lang <code>] [--agents]
create the workspace store this machine records project state in, and
point this machine at it. a store keeps its records in a git repository
this machine commits, or on a workspace server this machine is signed in
to; with neither flag and a person at the terminal, it asks which.
--git keep the records in a git repository here
--cloud keep them on a workspace server: sign in if this
machine has not, attach to a workspace, and pull it
--workspace <id> the workspace on the server to attach to, so
--cloud needs nobody at the terminal. with a person
there it is picked from this account's workspaces
--lang <code> language of the HTML views, as a BCP 47 code (en, ko, ja)
--agents tell this machine's agents about self without asking
With a flag the answer is stated. With neither flag and a person at the terminal it is asked. With neither and nobody there it is refused, rather than defaulted — this is not a decision undone by running the command again:
$ self init
error: a workspace store keeps its records in a git repository this machine commits, or on a workspace server this machine is signed in to, and nobody is at this terminal to be asked which — pass `--git` or `--cloud`
--git states the answer, and what follows it is the init that shipped in 0.12.0:
$ self init --git --lang en
workspace initialized at /tmp/rel13/fresh/.superself (views in "en")
The cloud answer attaches this machine to a workspace
--cloud runs the whole connect flow inside the same run: sign in if this machine has not, name the workspace, write workspace.json, and catch up on what the workspace already holds. A second machine attaches through the same self init --cloud; self workspace <path> and self connect are unchanged.
Which workspace is picked from a list. An account can be a member of several, so an id alone was something a person had to have written down. With a person at the terminal the memberships are listed by name and id and answered with a number or an id; a closed workspace is shown, marked, and refused as a choice; an account that is a member of nothing gets one sentence saying so rather than an empty prompt. --workspace <id> is what a non-interactive caller passes, and it is checked against that same list. Nobody at the terminal and no flag is refused before the list is asked for — nobody could be shown it.
Every way the flow can end short of finishing takes the directory back off the disk. An abandoned login, no workspace named, a workspace that does not answer, one this machine is not a member of — a run that did not finish leaves the machine exactly as it found it. Against a workspace that does not answer, that is what happens, and the directory it was run in is empty afterwards:
$ SUPERSELF_API_BASE=https://127.0.0.1:9 self init --cloud --workspace ws_7Kq2
error: the rail could not be reached
(exit 3)
$ ls -a
.
..
The one thing a failed flow may leave behind is a credential: a person approved a device on a page, and making them do it again because a later step failed spends their attention on nothing. The ctrl-c guard stays armed until the directory has actually been removed, and answers every press after the first with a line rather than an exit — so a second ctrl-c during the catch-up cannot leave the half store the guard exists to prevent.
self login asks for the workspace scopes. Seven of them — self.sync, self.read, self.write, artifact.read, artifact.write, repo.read, project.manage — beside the six the rail already used, and the granted list is kept in the profile. A credential granted before they existed is found by comparing that list rather than by reading a 404: the workspace API answers one indistinguishable 404 for a non-member, a call outside its scopes, and a project that is not there, so a client guessing "your credential is old" from one would be guessing wrong most of the time. The refusal names the profile it read and the login that writes it:
$ self project init --name second --desc "another project in this workspace"
error: the credential this machine reads — profile "default" — was granted before this workspace's scopes and cannot reach one: it is missing self.sync, self.read, self.write, artifact.read, artifact.write, repo.read, project.manage
self login
self project init registers with the workspace first. In a server-backed store the workspace makes the project and this machine registers what it made: a 201 writes the registry row carrying the server's project id, a 409 is a refusal naming both ways forward — self project link <slug> --here where it is this directory's project, and --name <slug> where it is somebody else's — and a 404 sends the caller to the connection and the account. A 409 is never resolved by guessing: GET /projects returns what a member can see, not what this account created, so the row for a crashed registration of this machine's own and the row for a colleague's project are the same row, and adopting one would bind a directory to another person's records. Under SUPERSELF_SYNC=off the whole thing is refused rather than done anyway — that setting stands the sync layer down, and registering a project is talking to the workspace. A git-backed store's project init is unchanged and asks nobody anything.
Bounds and refusals
-
A store is one kind for its whole life. There is no verb that converts one to the other, and
workspace.jsonis never written by anything but the connect flow.self initis refused rather than answered inside a mode check, because it makes the git repository and makes it outside every path a store's mode could turn into no work. -
A credential is never sent in the clear. The workspace base URL is
httpsonly; loopbackhttpis dev-mode only. A base that is neither is refused before any request:$ SUPERSELF_API_BASE=http://127.0.0.1:9 self init --cloud --workspace ws_7Kq2 error: http://127.0.0.1:9 is not https — a credential is never sent in the clear -
Nobody at the terminal is refused, not defaulted, for the one question whose answer cannot be taken back:
$ self init error: a workspace store keeps its records in a git repository this machine commits, or on a workspace server this machine is signed in to, and nobody is at this terminal to be asked which — pass `--git` or `--cloud` -
A workspace that does not answer is a refusal that leaves nothing behind, exit 3, with the directory as it was found — including the exclude line and the machine pointer, each undone only where this run made it:
$ SUPERSELF_API_BASE=https://127.0.0.1:9 self init --cloud --workspace ws_7Kq2 error: the rail could not be reached -
The five git verbs are refused by name in a server-backed store —
store size,store compact,sync,remote addandclone, each with the text in the section above.cloneis judged against the store the machine already points at, not the one about to be made, so an ordinary first clone on a fresh machine is untouched. -
A credential short of the workspace scopes is a refusal naming the profile and the login, rather than a 404 the client guessed at. Its text is in the section above.
-
An append is bounded where it is made, not where it is sent. An event's payload may be 256KB, and one append may carry 1,000 events or 1MB. An append cannot be divided after the fact, so refusing it at the moment it is made costs the caller one command, while refusing it at the moment it is sent would cost them a record stuck in a queue with no command that can take it back out. Every one of those refusals names splitting, because splitting is the only way through, and the per-event one also names
self artifact addfor the bulk. (These bounds sit behind the per-field text limits the command surface already enforces, which is what refuses first at a terminal; they were not reproducible through a command line for this document, so no output is pasted for them.) -
SUPERSELF_SYNC=offstands the sync layer down, and a verb whose work is talking to the workspace —self project initin a server-backed store — is refused rather than done locally anyway: a project registered here that the workspace did not make would have its every record queue behind a 404 nobody is watching. On a machine that is not signed in at all, the scope refusal above is what comes first. -
The catch-up refuses nothing, and the send says nothing. Neither is a place a command can fail. The one catch-up with nothing behind it to fall back on — the first one, during
self init --cloud— is the exception: it propagates a delta it could not write locally, refuses a pass that did not reach every registered project, and refuses a project list that arrived in a shape this CLI cannot read. Ordinary background sync is untouched and stays best-effort. -
The first catch-up's bound is per project, not per walk. 180 seconds is what one project is given, and it moves forward as each one finishes: a walk that keeps getting through projects is read to the end however many there are, and one that has stopped is still a refusal and still leaves nothing behind.
-
A prerelease is not releasable. Both packages publish with npm's default dist-tag, which is where every
npm i -g superselfreads from, and no channel policy exists to say otherwise. The gate refuses a prerelease or build-metadata version — the tag's own, andapps/fold/package.json's, which the tag never spells:$ node scripts/release-gate.mjs --tag v0.13.0-rc.1 the tag `v0.13.0-rc.1` names version 0.13.0-rc.1, and apps/cli/package.json is at 0.13.0 — a release tag is the CLI's own version and nothing else the tag `v0.13.0-rc.1` names 0.13.0-rc.1, which is a prerelease — prerelease channels are not decided, and this workflow publishes only finals: there is no dist-tag for it to land on but `latest`, which is what an install naming no version reads from
Mixed versions: a 0.12.0 CLI reading a store written here
Read off the fold rather than assumed. The v0.12.0 tree was checked out into its own worktree, installed and built, and pointed at stores this release's verbs wrote — one git-backed and one server-backed — and the same command list v0.12.0's notes used was run on both CLIs against both stores, with the answers compared byte for byte including exit code.
A git-backed store: nothing moved at all
Thirteen commands — status, context, work, work show <id>, state, log, artifact list, objective, milestone, milestone show <id>, search <term>, store size, lang — over a store carrying an objective, a milestone with exit criteria, a work unit with criteria and a report, a path artifact and a URL artifact:
IDENTICAL self status
IDENTICAL self context
IDENTICAL self work
IDENTICAL self work show w-q42pa
IDENTICAL self state
IDENTICAL self log
IDENTICAL self artifact list
IDENTICAL self objective
IDENTICAL self milestone
IDENTICAL self milestone show m-5g6h4
IDENTICAL self search release
IDENTICAL self store size
IDENTICAL self lang
Thirteen of thirteen byte-identical, exit code included. This is the check on the fold extraction's central claim: the calculation moved into a package and nothing a reader sees moved with it. Nothing refused, and there is no row to write.
A server-backed store: the older CLI reads it as git-backed, and sees an empty project
This is the direction that matters, and the one to know before you mix versions. A 0.12.0 CLI does not know .superself/workspace.json and has no reason to look for it, so it reads a server-backed store as the only kind it knows — a git-backed one. It therefore reads log.jsonl, which in a store like this is either absent or holds only what has come back from the server, and it never looks at pending.jsonl at all. Everything this machine has recorded and not yet sent is invisible to it.
| What the store carries | 0.12.0 | Why |
|---|---|---|
.superself/workspace.json |
ignored — the store reads as git-backed, which is the only kind 0.12.0 has | the marker is new here; existence is the whole mode test, and 0.12.0 runs no test at all |
projects/<slug>/pending.jsonl |
ignored — the file is never opened, and every unsent record in it is unread | readEvents merging the queue with the server's copy is new here; 0.12.0's reads log.jsonl and stops |
a queued goal — self status |
read as absent — demo — goal: (not set) against 0.13.0's demo — goal: the workspace holds what every machine recorded |
the goal is in the queue, and the queue is the file 0.12.0 does not read |
a queued work unit — self work |
read as absent — no open work against 0.13.0's one open unit |
as above |
a queued entity — self state |
read as absent — no live entities against 0.13.0's 2 live entities |
as above |
queued events — self log |
read as absent — no rows and no size line, against 0.13.0's two rows and 2 events |
as above |
self context |
read as the project header alone — the name and the description, with no goal line and no ## Work in progress section |
the header comes from the registry, which 0.12.0 does read; everything below it is folded from events it cannot see |
self store size in a server-backed store |
answered, about the wrong thing — it reports the .git the store's records do not live in (store 151.3 KB — working tree 123.1 KB, .git 28.2 KB, 12 loose (48.0 KB)), where 0.13.0 refuses the verb by name |
the refusal is a mode check 0.12.0 does not have; the number it prints is true about that directory's git repository and says nothing about the workspace's records |
self artifact list, objective, milestone, search, lang |
identical — both answer the same, because in this store both answer about nothing | the queue holds no artifact, objective or milestone; these rows say the surfaces do not diverge on their own, only through what they can read |
registry.jsonl rows carrying the server's project id |
read, minus the id — the project lists as it always did | an unread key on a row 0.12.0 already parses; the id is what the background push reads and nothing a render shows |
actor.account on an event |
ignored — no authorship beyond the by note 0.12.0 already renders |
an unread key on a payload whose named fields are unchanged |
the surfaced field on a blocked queue row |
not applicable — it is in pending.jsonl, which 0.12.0 does not open |
the queue is entirely new; nothing in it is read, so nothing in it can be misread |
Nothing here is corruption, and nothing is refused. Every one of those commands exits 0 on 0.12.0. That is precisely what makes it worth stating: a 0.12.0 CLI in a server-backed store reports a project that looks empty rather than a store it cannot read, and there is no error to notice.
And it will write there. A 0.12.0 CLI records into a server-backed store the only way it knows — into log.jsonl, committed:
$ self decide "the older CLI writes here" --why "to see where the record lands" # 0.12.0
entity.confirmed recorded [01m1fzvhx65d48st1wvp314dvb]
the older CLI writes here — verify; wrong? self undo 01m1fzvhx65d48st1wvp314dvb
commits=3 log.jsonl=1 pending.jsonl=2
The 0.13.0 CLI still reads that record — the merged read takes the server's copy first and this machine's unsent tail after it, so the 0.12.0 record sorts ahead of the two queued ones exactly as a record the workspace had already agreed on would:
$ self log # 0.13.0, the same store
2026-09-02T02:41:02.374Z entity.confirmed [01m1fzvhx65d48st1wvp314dvb] the older CLI writes here · by agent
2026-09-02T02:40:37.751Z entity.confirmed [01m1fztsvqdd3v6n35ft5yrwk2] the workspace holds what every machine recorded · by agent
2026-09-02T02:40:37.885Z entity.confirmed [01m1fztszx622qn2pxwgpz0tb4] a second machine reads what this one queued · by agent
3 events
But the workspace will never learn about it. The record was written into the server's copy rather than queued, and log.jsonl is only ever written by what comes back from the server — so nothing will ever send it. It is a local record in a store whose whole point is that its records are not local. Do not run a 0.12.0 CLI against a server-backed store. A store made with self init --cloud should be used only by CLIs that know what it is.
The other direction
A store written by 0.12.0 reads here exactly as it did there. It carries no workspace.json, so it is git-backed, which is what it has always been and what every path in this release leaves alone. No migration, no back-fill, no new refs key, and no file gains a field. The thirteen-command comparison above is that claim, run.
Two packages from one tag
superself and @superself/fold are published from one tag, and it is worth knowing the rule because it is visible on the registry.
- The tag
v0.13.0names the CLI's version. The CLI is the product; the fold is the calculation it and a server share. - The CLI depends on the fold by exact version —
"@superself/fold": "0.1.0"— never a range. A range would let one tag mean two different pairs depending on when somebody installed it, and "which fold is in CLI 0.13.0" would be a question you answer by reading a resolution rather than a number. - The fold is published first, and the CLI only after it succeeds. An extra fold version nothing depends on is inert; a CLI whose dependency is not on the registry is installable by nobody. The order is what makes a half-finished release harmless.
- Because the two are coupled, a release that changes nothing in
apps/foldstill raises the fold's version, because a version already on the registry is refused. That is the cost, and it is one line in the release pull request.
@superself/fold@0.0.1 is already on the registry and is not a release. It is a placeholder, published by hand once, for one reason: npm's Trusted Publishing configures a trusted publisher on a package, so it cannot be configured for a package that does not exist, and the first npm publish of a new name under OIDC would fail with no way around it. The placeholder is a version below anything the project will ever release; it exists so that every publish after it is tokenless. 0.1.0 is the fold's first real release.
For users of the CLI, none of this changes anything. npm install -g superself@0.13.0 installs the CLI and pulls the fold in as its dependency, exactly as before. You do not install @superself/fold, and nothing about the self command asks you to know it is there.
The gate that holds all of it runs before anything uploads:
$ node scripts/release-gate.mjs --tag v0.13.0
v0.13.0 may be published
Upgrade after publication
After the v0.13.0 release workflow finishes, install the published package:
npm install -g superself@0.13.0