Executive reference for the whole system: what exists, how the pieces relate,
what happens on the wire when an agent does something. Generated by reading
the actual source — src/taskops, plus the dashboard's own TypeScript source
under ui/src and ui/smoke, which §15 describes — and the tests (uv run pytest). The size of both is a command rather than a number here, because the
number rotted twice (it said 97 Python files at 116, and 45 dashboard files at
66) while the command cannot:
find src/taskops -name '*.py' | wc -l # …and | xargs cat | wc -l for lines
find ui/src ui/smoke \( -name '*.ts' -o -name '*.tsx' \) ! -name 'index.generated.ts' | wc -lEvery number below is meant to be re-derived, never trusted. This file describes what is built — if it ever disagrees with the code, the code is right and this is a bug.
Companions: README.md (install it, run it, serve the UI) ·
CLAUDE.md (how to work in this repo).
The v1 post-mortem behind each rule is inline, in the docstring of the module that carries it — that is on purpose: a design document nobody opens is how a rule outlives its reason. §11 and §14 are the index of those reasons.
taskops v2 is a work board — milestones → cards → subtasks — for teams of coding agents working in parallel under one human who decides what merges. It replaces a v1 system (~340 files) that had, in production: cards permanently stuck "in progress" after a worker died, races between two machines both convinced they owned the same card, automatic merges that broke checkouts out from under a working agent, and 35 CLI commands duplicating what a smaller surface could do once.
Four decisions eliminate those failure classes by construction, not by adding a command that reacts to them after the fact:
- Only three facts are ever stored (
open,done,dropped). Everything else a board can say about a card —ready,doing,blocked,stalled— is computed at read time from the dependency graph and from who currently holds a live lease. A worker that dies simply stops renewing its lease; its card falls out ofdoingwith no sweep, no timeout job, and no verb that "fixes" it, because there is nothing written to be wrong. - A branch is inhabited, not switched. Every card gets its own git
worktree, pinned to its own branch for its whole life.
git switchdoes not appear anywhere in the codebase. Two agents on two different cards are two directory trees sharing nothing; git itself refuses a branch checked out in two worktrees at once, which is a lock nobody has to remember to take. - Two roles, enforced once, in one table.
verbs/__init__.pydeclares for every verb whether it reads or writes and which role may call it. The HTTP router, the local board, the MCP tool layer and the tests all defer to this one table — v1 re-took the same decision by hand at 25 call sites and four of them disagreed. - Context rides in the tool result, not in a hook that decides. One
delivery-only Claude hook exists; it reads and injects,
never writes. What a worker needs to act — the milestone's goal, the full spec,
the whole comment thread, file collisions with other live work, the
previous worker's handover note, its own worktree path — comes back
in the response to
taskops_take, ordered so that what changes what you do before you start is above the spec, not after it.
Storage is an append-only event log (the only truth) plus two SQLite files that are both derived and both disposable — except that leases are alive, not derived, and live in a file the cache-rebuild path can never touch.
erDiagram
MILESTONE ||--o{ CARD : "contains"
CARD ||--o{ CARD : "parent → subtasks (tree)"
CARD }o--o{ CARD : "after → depends on (DAG)"
CARD ||--o{ EVENT : "task → history"
CARD |o--o| LEASE : "id → held by, right now"
ACTOR ||--o{ EVENT : "actor → wrote"
ACTOR ||--o| LEASE : "actor → holds"
MILESTONE ||--o| WORKTREE : "branch ms/<slug> → integration tree"
CARD ||--o| WORKTREE : "branch tk-<id> → worker's tree"
MILESTONE {
string id
string title
string goal "the WHY, travels inside every take"
list rules "what holds for EVERY card of this chapter"
list criteria "what the CHAPTER is accepted against — shown at landing"
string branch "ms/slug, computed ONCE, stored"
string status "open | done | dropped"
float created
}
CARD {
string id "tk- + 6 hex"
string title
string spec "the brief — most important field"
list criteria "acceptance checklist, not a gate"
string status "open | done | dropped — STORED"
int priority "0 urgent .. 3 someday"
string milestone "FK"
string parent "FK, epic — the TREE"
list after "FK list — the DAG"
list files "edit surface, a hint, never a lock"
list labels
string assignee "who it is FOR — not a claim"
int progress "0-100, the worker's own REPORT — absent until one is made"
string created_by
float created
float updated "replay arbiter, newer-wins"
}
EVENT {
string id "sha256(canonical)[:32] — idempotent"
string task "or project for board-level facts"
string actor
string kind "created|edited|claimed|released|status|comment|commit|merged|milestone|project|submitted|reviewed"
object body "comment carries an optional mentions[] — an extra key, not a new kind"
float ts
}
LEASE {
string task PK "the PK is the mutex"
string actor
string branch "tk-<id>"
float acquired
float expires "LEASE_TTL = 900s, renewed by every call"
}
ACTOR {
string kind "dev | agent"
string owner "agent's dev, if kind=agent"
string name
}
WORKTREE {
string path
string branch
}
Reading this diagram: CARD.status is the only status a row ever
carries. doing is not a value anywhere in this schema — it exists only as
the join between CARD.id and a LEASE row that has not expired. Delete the
lease (or let it expire) and the card's displayed state changes with zero
writes to CARD itself.
stateDiagram-v2
[*] --> open : taskops_plan
open --> done : status=done (needs a commit, or no_code=true+note)
open --> dropped : status=dropped (reason required)
done --> open : status=open (reopen)
state open {
[*] --> ready
ready --> doing : taskops_take (lease acquired)
doing --> ready : lease expires (worker died) — NO event, NO writer
doing --> stalled : lease expires AND assignee still set
stalled --> doing : taskops_take (new holder)
ready --> blocked : an `after` dependency is not closed
blocked --> ready : that dependency closes — no event on THIS card
doing --> review : status=review (card.review only) — `submitted`, history-only
review --> reviewing : taskops_review task= (the REVIEW lease)
reviewing --> review : that lease expires — NO event, NO writer
reviewing --> changes : verdict=changes — `reviewed`, history-only
changes --> doing : the worker picks it back up
review --> review : status=review again (a new round; the old verdict is stale)
}
Only the outer transitions (open → done → dropped, and back) are events in
the log. Everything inside the open box — ready, doing, blocked,
stalled, and the three optional review states — is
core/graph.py:derived(), computed fresh on every read from
(card.status, card.after[], live leases, live REVIEW leases, standings).
review, reviewing and changes appear only on a card with review: true;
both new parameters default to "nothing under review", so a board that never
turns it on derives exactly as it did before the feature existed
(tests/test_core.py::test_a_card_without_review_derives_exactly_as_before). There is deliberately no path in
this diagram labelled "recover": nothing is ever wrong for a recover to fix.
progress (0–100) is the one field that is neither: STORED, like the three
statuses, and yet no state at all — nothing derives from it, nothing gates on
it, done does not write 100, and a card nobody has reported on has no key
rather than a zero. It is a worker's own sentence about its own card
(verbs/update.py::_progress), written with taskops_update progress= every
5–10 points, guarded exactly as a release is (yours, and never a stranger's).
It is not a derived state because deriving it would mean the board guessing how
far along somebody is; it is not a lifecycle status because nothing may ever
branch on it. What it buys beyond the number is the heartbeat: the write is MCP
traffic, and MCP traffic is the lease's only proof of life (§12), so a worker
that reports is a worker that cannot read as stalled while it is reporting.
One more fact is derived the same way and is about a reader, not a card: a
pending mention (core/mentions.py). Being named in a comment's optional
mentions[] is pending until that actor writes any event on the card, or the
card closes — computed from the thread on every read, so answering is what
clears it. There is no read column and no ack verb, for the same reason
there is no recover: both would be a write whose only job is to contradict
an earlier one.
A third one is about a chapter: its reports (core/reports.py). A report
is a file COMMITTED under .taskops/reports/, registered by the filed verb
with one history-only report event carrying {path, title, milestone, sha} —
a pointer, never the prose, the same rule that keeps diffs out of the log. "Which
reports does this chapter have" is reports.of() over those events on every
read, newest first, capped with reports_total beside it — never a table and
never a column on Milestone, because a stored list is a second fact somebody
has to keep in step with the events that produced it. core/reports.py::under()
is the ONE place that decides whether a path is a report path, and both ends ask
it: the verb that registers one, and the /git door that later serves its bytes
to the dashboard.
flowchart TB
subgraph L0["0 · foundation (stdlib only)"]
errors["_errors, _ids, _clock, _json, _locate, _version"]
wire["_wire — one POST, one envelope: the decoder both clients share"]
end
subgraph L1["1 · core (PURE — no I/O)"]
types["types — Card/Milestone/Event/Lease (re-exports KINDS and the actor grammar)"]
kindsm["kinds — the event-kind registry: replayed? required body keys"]
actors["actors — presence, folded from events"]
event["event — construct + hash"]
replay["replay — fold(events) → State"]
chapters["chapters — the milestone half of the fold: create/status/landed/edit"]
machine["machine — transition guards"]
graph["graph — ready/blocked/doing/stalled"]
scope["scope — the SERVER-scope roles: owner | member | anon"]
challenge["challenge — the login nonce: in memory, single-use, dead on claim"]
mentionsm["mentions — who was named and has not answered"]
reviewm["review — submitted/reviewed folded into a Standing"]
reportsm["reports — is this a report PATH, and the chapter's list as a fold"]
holdingm["holding — 'is this history held here?', by event id: board pull + board rm"]
forgem["forge — the forge vocabulary: which host can be asked, what access counts, owner/name"]
seamsm["seams — which ready cards name the same CONCEPT, from the specs alone"]
hours["hours — working-time math, and the calendar windows to report it over"]
end
subgraph L2["2 · store (the ONLY SQL)"]
log["log.jsonl — append + fsync"]
cache["cache.sqlite — derived, disposable"]
live["live.sqlite — leases + presence, NEVER disposable"]
reviewsdb["reviews — the REVIEW lease, a second mutex in live.sqlite"]
creds["creds — invite tokens, per board"]
serverdb["server.sqlite — the HOST's principals + pubkeys, and allowed_signers derived from them"]
pubkeysm["pubkeys — the ssh key grammar: parse, fingerprint, name"]
stores["stores.py — journal → index → fold, in that order"]
end
subgraph L3["3 · verbs (+ the REGISTRY)"]
plan["plan"]; take["take"]; update["update"]; card["card"]
assign["assign"]; pulse["pulse — the board"]; record["record"]; report["report"]
mentionsv["_mentions — the ✉ read"]; waitingv["_waiting — the ◆ read"]; eventsv["events — the log, paged"]
reviewv["review — claim a submitted card, or record the verdict"]
projectv["project — board-level facts: op=remote, op=visibility, op=forge"]
helpersv["_args _cards _chapter _context _facts _rows _stories _windows — the helpers; the _ says 'not a verb'; _windows owns what a window= spelling MEANS"]
registry["__init__ — Verb(fn, kind, roles, refusal)"]
end
subgraph L4["4 · board.py + gitwork (the ONLY git, client-side)"]
board["board.py — LocalBoard | RemoteBoard, routing decided ONCE"]
run["gitwork/run"]; trees["gitwork/trees — worktrees: the GEOMETRY"]
landing["gitwork/landing — the MERGES: card→chapter, chapter→trunk"]
catchup["gitwork/catchup — one worktree, up to date with a branch; the chapter's declared union_files fold, everything else still aborts"]
trailer["gitwork/trailer"]; bind["gitwork/bind"]
install["gitwork/install — what GIT needs: hooks, gitignore, the address"]
claudef["gitwork/claudefiles — what CLAUDE reads: .mcp.json, settings.json"]
remote["gitwork/remote — origin: {host, slug, url}, and best-effort push"]
diff["gitwork/diff — the walls and the range arithmetic"]
patchm["gitwork/patch — a resolved range → text and numbers"]
sigm["gitwork/sig — SSHSIG: ssh-keygen -Y sign / -Y verify"]
sessionm["session.py — the token's life: mint, refresh, remember"]
identitym["identity.py — WHO signs in, with WHICH key: discover, establish"]
end
subgraph L5["5 · transports"]
mcpsrv["mcp/server, hello, tools, gitmoves (assign + merge dispatch), integrate (a CARD lands), chapter (a MILESTONE lands: gate, catch-up, record), dossier, before, render, brief, activity, boardview, schema, orders, thread, boards, fields"]
httpsrv["http/server (lifecycle), handler (one method per door), mounts, watcher, rpc, admin, scoped, grants, ingest, removal, auth, login, members (the owner's enrol BATCH), feed, static, gitdoor, upstream"]
end
subgraph L6["6 · cli"]
cli["commands: init · join --key/--invite · enrol: the invite introduction · github + team: the OWNER's forge sync, and WHERE a GitHub token may come from · hooks: what the two git hooks call · watch: join with nothing · serving: serve · ui · remote: remote add — which host, which board · operate: board · board visibility · board forge · grants: invite · revoke · push: board push · pull: board pull — the same five steps backwards, over paging: the whole log through the `events` verb · rm: board rm — the guardrail · admin: server init + break-glass"]
end
L1 --> L0
L2 --> L1
L3 --> L2
L4 --> L3
L5 --> L4
L6 --> L5
tests/test_architecture.py enforces this by reading the AST of every module:
imports only point down, subprocess appears only in gitwork/run.py, sqlite3
only under store/, the wall clock only in _clock.py and core/hours.py
(the one module whose whole subject is time), and every module stays
under 200 lines — a budget that forces a split to land on a cohesive boundary
instead of an arbitrary one.
A leading _ marks plumbing for the layer above, and it is a three-zone
convention: the package root (_errors _ids _clock _json _locate _version _wire are level 0; board.py, session.py and identity.py are that layer's
doors), and verbs/ (_args _cards _chapter _context _facts _mentions _rows _stories _waiting _windows are helpers — the un-prefixed files are the registry's entries, one per verb).
Nowhere else carries it, because every module under core/ store/ gitwork/ http/ mcp/ cli/ is internal to its layer and there is nothing to distinguish;
import taskops exposes five errors and a version, so module names are a
contract with nobody. This is NOT anthropic-sdk-python's convention, where _
marks the half of a library users must not import — taskops has no such half,
and renaming a package to _core/ to resemble one would be cargo cult.
Zero headroom is a finding, not a pass (audited 2026-08-09). The budget test
is <= 200, and it was green with SIX modules at exactly 200 and fifteen within
ten lines — a seventh of the package that could not accept one line, so the next
card to touch any of them would have had to split it under pressure, which is
the one condition under which a split lands somewhere arbitrary. Eight modules
were split in cold blood instead, each at a seam that already existed:
verbs/pulse → _rows, session → identity, http/server → handler,
gitwork/trees → landing, gitwork/install → claudefiles, gitwork/diff → patch, core/replay → chapters, verbs/plan → _cards. The cap is now 196 and
nothing sits on the line. Re-derive it rather than trusting this paragraph:
find src/taskops -name '*.py' -exec wc -l {} + | awk '$2!="total" && $1>=190' | sort -rnflowchart LR
dev["dev:<name>\nORCHESTRATOR"]
agent["agent:<dev>/<name>\nWORKER"]
dev -->|"plan"| W1["write the whole tree"]
dev -->|"assign (dispatch)"| W2["assign cards, cut worktrees"]
dev -->|"merged"| W3["record a card merged into ms/*"]
agent -->|"take"| W4["acquire the lease, get the whole card back"]
dev -->|"board, card, report"| R1["read"]
agent -->|"board, card, report"| R1
dev -->|"update"| W5["comment (+mentions) / close / release / drop"]
agent -->|"update"| W5
dev -->|"review"| W6["claim a submitted card, or record pass/changes"]
agent -->|"review"| W6
dev -.->|"REFUSED"| W4
agent -.->|"REFUSED"| W1
agent -.->|"REFUSED"| W2
agent -.->|"REFUSED"| W3
The rule lives once, as data, in verbs/__init__.py::REGISTRY:
| verb | kind | role | if the wrong role calls it |
|---|---|---|---|
board |
read | both + anon | — |
mentions |
read | both + anon | — (a delivery-hook read: one of the two that renew no lease; for anon it is empty by construction) |
waiting |
read | dev |
"these are the orchestrator's moves, not yours. Your own picture: taskops_board" — the other non-renewing read: MERGE / REVIEW / STALLED for the delivery hook |
card |
read | both + anon | — |
report |
read | both + anon | — |
events |
read | both + anon | — (the LOG, one keyset page at a time, newest first; board-wide by construction — see verbs/events.py) |
plan |
write | dev |
"workers do not plan the board. Report what you found instead: taskops_comment task=<yours> text="…"" |
assign |
write | dev |
"dispatching is the orchestrator's move. Take what is yours: taskops_take" |
merged |
write | dev |
"workers do not integrate branches. Close your card and the orchestrator merges it" |
take |
write | agent |
"you are the orchestrator — you dispatch, you do not hold cards" |
update |
write | both | — |
review |
write | both | — (optional review: claim a submitted card, or record the verdict. A verifier is an ordinary agent — there is no reviewer role) |
bind |
write | both | (internal — the git hooks call it) |
project |
write | both | (internal — board-level facts: op=remote, where the repo lives on the web; op=visibility, public or private; and op=forge, the repo whose membership opens the board — absent by default, and core/forge.py owns its shape) |
A PUBLIC board: anyone may watch, nobody writes without a key (2026-08-09).
GitHub's model, deliberately and exactly — private by default, the owner may
publish (taskops board visibility <host>/<name> public|private, an owner-only
server-scope operation), public means ANONYMOUS READ, and a write always needs a
registered key. No third state and no anonymous-write grace.
board.forge is that same shape one notch sharper — owner-only, one board, a
value from a closed set — because it decides whose GitHub membership becomes a
key here. Both are verbs/project.py facts reached through http/admin.py's
REGISTRY, and neither has a get half: the board payload already answers.
The flag is a project-level EVENT beside remote, folded newest-wins, so "when
did this become public and who did it" is in the log; absent, a board is private,
which is why no board that predates the feature changed. It is derived onto the
board payload, and _context.py::pulse echoes the actor the server resolved —
so a reader can be told it is anonymous BEFORE it types, which is what the
dashboard's comment box does instead of offering a form that always 409s.
The whole difficulty was that a read writes. Every read verb opens with
stores.live.renew(actor, now), which UPDATEs the leases that actor holds and
INSERTs a presence row: a public board without a guard would have every visitor
writing to live.sqlite on every page load — no event, no card, nothing any
screen would show. The guard is in store/live.py::renew, the ONE place that
writes, and not at the six callers, for the reason this file's §4 exists: a
seventh read verb gets written next month by somebody who never read the rule.
tests/test_topology.py hashes events.jsonl, live.sqlite and its -wal
before and after a full anonymous crawl — every read verb, twice, plus /feed —
and asserts they are byte-identical. cache.sqlite is excluded on purpose: it is
derived and disposable, and deleting it rebuilds it.
/feed opens for anon on a public board and that is not a relaxation: a message
there is {"type": "change", "verb", "seq"} and NOTHING else, so a watcher
learns only that the board moved and answers by re-reading through /rpc, where
the gate applies again in full. The day a message carries a card, that door
closes with it (http/feed.py carries the argument).
Anonymous is refused a write TWICE — http/auth.py::anonymous never hands out a
credential for one, and the registry refuses the role — and over a socket each
guard silently answered for the other: deleting either left the suite green.
Both are now asserted against their own function
(test_each_of_the_two_write_walls_stands_on_its_own). Anonymous may also not
CLAIM to be somebody: actor travels in the call, so authorize refuses an
anon credential naming any other actor.
taskops join <url> with no invite against a public board is a READ-ONLY join
(cli/watch.py): the board is ASKED rather than assumed, config is written with
readonly: true — recorded and not inferred from an empty token, because that is
also what a BROKEN join leaves — nothing is minted, no key is registered, and
gitwork/remote.py::remember is deliberately NOT called, since recording this
repo's origin would be the anonymous write the rule forbids. taskops ui then
serves the viewer's window: an Upstream with no bearer, which is exactly what
Mounts.public reads to let the local browser in while the REMOTE decides.
Server scope is a SECOND registry, in the same shape (core/scope.py,
added 2026-08-09). verbs/__init__.py answers what may you do on a board;
scope.py answers what may you do to the HOST — create a board, list them,
register a key, mint an invite. Three roles and no fourth: owner (the
principal that bootstrapped this host), member (a registered key that is not
the owner), anon (no credential — reads a public board, writes nothing).
GitHub's model, deliberately. scope.permit(operation, role) is the one gate
and its refusal names the role that may and how a key gets registered.
No board comes into existence by accident (the hole closed on 2026-08-09,
found the day before). http/mounts.py::stores() used to do
Stores(self.root / name) for any name matching the pattern, and Stores
makes its own directory — while http/server.py calls mounts.check(board)
BEFORE self._credential(...). So an anonymous request for a name nobody had
ever used left a board directory with a cache and a lease file on the server's
disk: a write caused by a stranger's question. stores() now only OPENS; an
unknown board is 404 with no side effect
(tests/test_topology.py::test_an_unknown_board_is_404_and_leaves_nothing_on_disk),
and creation is Mounts.create(), reached only through the owner-only
board.create operation.
The one ssh in the design. taskops server init --root <dir> --key <path.pub> (cli/admin.py) writes the first principal as owner and is meant
to be run on the host itself. Trust has to enter through a channel that
predates the system, and the machine's own shell is that channel: a remote
bootstrap would need a credential this host does not have yet. Every admin act
after it is a signed call over the API. The allowed_signers file beside
server.sqlite is DERIVED — regenerated whole from the store on every change,
never hand-edited, never read back into the store — the same relationship
cache.sqlite has to events.jsonl. Bearer tokens (store/creds.py, per
board) are untouched: keys are how tokens come to be MINTED.
A key authenticates a LOGIN; the login mints a SESSION (http/login.py,
core/challenge.py, gitwork/sig.py, session.py — 2026-08-09). POST /login
answers a random, single-use, in-memory nonce; the client signs it with
ssh-keygen -Y sign -n taskops; the server runs ssh-keygen -Y verify -f <root>/allowed_signers -I <principal> and, if OpenSSH says yes, mints an
ordinary bearer credential with a 12-hour TTL. Every downstream path is
UNTOUCHED — /rpc's bearer check, /feed, the MCP board all see the token they
always saw. That is why the session model beat per-request signing: a signature
envelope would have to be taught to the stdlib HTTP server, to the WebSocket
handshake and to the MCP layer, three times. Exactly one thing changed: where
tokens come from.
The signed payload is a SERVER-ISSUED CHALLENGE and not a client timestamp, so
there is no clock-skew window to pick and no replay cache to size — the nonce
dies on the claim (core/challenge.py carries the argument, and why it is
memory and never a table: a challenge that outlived its process would be a
stored fact whose only job is to be contradicted, which is recover's shape).
The subprocess runs through gitwork/run.py::tool, the ONE module allowed to
start a process, and tests/test_architecture.py still enforces exactly that.
ZERO pip dependencies, no hand-rolled crypto.
The host is OPERATED from taskops, over its own API (http/admin.py,
cli/operate.py — 2026-08-09, the anomaly this chapter exists to kill). Five
acts that used to be an ssh session on the box are now signed calls from the
laptop:
taskops remote add <url> the host, once per checkout — git's origin
taskops board create [<name>] owner only — THE way a board comes to exist
taskops board ls [<host>] owner: all of them; member: their own
taskops board push [<name>] a LOCAL board becomes the hosted one
taskops board pull [<name>] and back down: a SNAPSHOT here, the host untouched
taskops board visibility [<name>] public|private owner only
taskops board rm [<name>] [--discard-history] owner only — the only one that DESTROYS
taskops board forge [<name>] <owner>/<repo> [--need push|admin] owner only
taskops board forge --clear back to invite-only — opting in is reversible
taskops invite <who> --board <name> owner only — prints the join line
taskops revoke --key SHA256:… | --invite <id>And they read like git: host once, key discovered, verbs bare
(cli/remote.py, 2026-08-09 — the human's words: «esto debería ser como git …
pero sin --key»). Git asks for neither a URL nor an identity file on every push,
and both reasons are copied rather than invented. The ADDRESS is recorded per
checkout — taskops remote add <url> writes login.host into
.taskops/remote.json, the uncommitted per-machine file a join --key already
cached the same field into, which is git's .git/config and NOT the host alias
registry §11 refuses: what was refused is a TABLE, many names and global; this
is ONE host, in the file that already holds it, acquired by an explicit command
instead of only as a side effect of a join the owner on day one cannot run.
board create records the NAME it chose in the same block, so the bare board push after board create minombre does not re-derive the directory and make
the human repeat a name — precedence is explicit argument > recorded name >
directory (gh repo create's convention). The block is merged FIELD BY FIELD
(_locate.py::write_remote) because three writers own three of its fields and a
whole-block update would let a sign-in silently drop the recorded name. The KEY
is DISCOVERED the way ssh discovers one — ~/.ssh/id_ed25519, id_ecdsa,
id_rsa, ssh's own files in ssh's own order, in ONE function
(identity.py::discover_key) that establish consumes so no verb guesses for
itself; --key stays as the override, exactly as ssh -i is, and the refusal
when none exists lists what it tried. Recording an address is not signing in:
remote add mints nothing and touches no key. Creating a board on a DIFFERENT
host from inside a joined checkout records nothing here — the same rule
session.is_own_host holds the token to, and the suite caught the first version
without it.
A key is accepted by every one of them, and that is what
makes the FIRST one runnable (identity.py::establish, 2026-08-09 — found by
running the chapter end to end on a clean host, not by a test). Without it the
owner of a new box was deadlocked: operate.call only knew the session a
previous join --key had cached, the invite that join wants is minted by
taskops invite, which wanted a session of its own, for a board nobody had
created yet — so day one on every host fell back to ssh, the exact anomaly this
chapter exists to kill. establish is push.py's already-working key-to-session
logic, lifted so both consume ONE implementation; --as <principal> overrides
the $USER guess (a machine whose unix user is not the principal's name could
not sign in at all before it); the login block is cached once the host accepts
the signature, so the second command needs no flags; and the refusal names
--key beside join. On revoke the signing key is --sign-key, because
there --key is already the fingerprint being retired. The four scenarios go
through the real main() and argv in tests/test_topology.py — the wiring was
what was broken, and calling the functions is what hid it.
They are SERVER-SCOPE verbs in a registry of their own, gated by
scope.permit, authenticated by the session an ssh key mints, and they answer
on the root /rpc — one segment, because admin is a legal board name and
/admin/rpc would be that board's own door character for character. The
envelope is the board rpc's, so _wire.py is the client for both.
board.create refuses an existing name (Mounts.create is
mkdir(exist_ok=True), so without the refusal it would hand somebody else's
history back as new) and writes WHO made it as the board's first project-level
event — the one fact no later event could reconstruct. A member's "their own
boards" is DERIVED from the credentials they hold, never a membership table.
The scp dies: taskops board push promotes a local board (cli/push.py,
http/ingest.py — 2026-08-09). Moving a board used to be a README paragraph
naming events.jsonl and a shell on the box — storage leaking into UX, with no
count and no verification. It is now one command, and the ORDER is the
safety, with the config flipping LAST: (1) the target must exist and hold no
history but its own beginning, (2) no live lease here, (3) the whole log is
streamed through the server-scope board.ingest, (4) the counts are compared,
per event KIND and not just a total, and a mismatch is a STOP, (5) only then
board.json/remote.json, with .taskops/board/ renamed to
board.local-<date>/ rather than deleted. A failure at any point above leaves
the repo exactly as it was.
Three decisions inside it, each pinned in tests/test_topology.py:
- No force flag, and there must never be one. A non-empty target means two histories, and giving them an order they never had is fabricating a timeline; the refusal says exactly that instead of offering a way.
- The ingest verb is not a sync channel and cannot become one. Its
precondition is true exactly once in a board's life. "Empty" is not
seq == 0but "no history but its own configuration" —board.createwrites WHO made the board as its first event, and setting the visibility or recording a remote — or naming the forge that opens it — before the board is filled is a fact about the CONTAINER, not about the work. So the door exempts project events whoseopis in the CLOSED list{created, visibility, remote, forge}— and only while the board holds ZERO card events; one event about work and the exemption narrows back to the birth certificate (ingest.py::_configuration, whose docstring is the 2026-08-09 reproduction: this repo's own board was created and made public before it was pushed, and the wall refused a push with nothing to merge). It appends through the store path replay already trusts and does NOT re-judge: events were validated by the verbs that made them, so the door checks the HASH and moves them. Retry is free by construction — ids aresha256of the content, so an interrupted push re-runs to a no-op and continues, which is why the whole history travels in ONE call (the set comparison that separates a retry from a stranger's board is only decidable with all of it in hand). The payload is deduplicated against ITSELF as well:Stores.writeappends everything it is handed to the log while the cache ignores a repeated id, so a payload naming one line twice would put two lines in the truth and one row in the index. - Leases and presence do not travel.
live.sqliteis a fact about running processes, none of which will run against the new host; the board starts with nobody working, which is true. A held lease refuses the push rather than being copied.
And taskops join refuses to orphan a local board (cli/commands.py, the
same day). Joining a repo whose .taskops/board/ held events simply started
reading the remote one: the history stayed on disk, byte for byte, and nothing
ever looked at it again or said so. It is now a STOP naming both ways out —
taskops board push to take it along, or --discard-local to archive (never
delete) and join anyway. It counts EVENTS, not the directory, so the ordinary
init → join sequence is untouched.
A dev's join carries nothing about GitHub, and did for one day (deleted
2026-08-11, §19.1). taskops join --github posted the joiner's own GitHub token
for the host to verify against the declared repo; it was the --invite path with
a different proof, and it was the right shape for the wrong side. The owner's
board forge already knows the team, so the fact is established once, on their
laptop, and a dev whose key that sync published joins BARE: taskops join, the
address carried by the clone, the key discovered the way ssh discovers one, the
challenge signed on the spot. Nothing of theirs travels — which is stronger than
never storing what did. A GitHub token is never a flag value survives the
flag it was written for and now lives beside the only caller left
(cli/github.py::token, §19.1): gh auth token, else $GITHUB_TOKEN, else a
HIDDEN getpass prompt, because a secret passed as an argument is in the shell's
history file before the process starts and in ps while it runs.
The on-box commands survive as break-glass: `taskops invite/revoke --root
` still work against the files, on the machine that holds them, for the day the server is down or the owner's key is lost. Not deprecated — a system whose only door is its own API cannot be repaired when that API is what broke. And there is deliberately **no `taskops host add`**: the host is already recorded per project by the join that registered the key (`remote.json`'s `login.host`), so an alias table would be a third place a server's address lives and the first to drift.The client half is session.py: board.py::open_board refreshes an absent or
expiring session before it hands a board back, and RemoteBoard.call retries
ONCE when the server says a credential expired — because the MCP server opens a
board once and keeps it for a session longer than the token. A config with no
login block, and a token with no expiry, are never touched: that is the shape
production's four boards are in. One limit, stated and not solved: -Y sign
needs the private key FILE, so an agent-only setup is refused in one sentence
naming the limit rather than taught the ssh-agent protocol.
POST /login {"principal": "berna"} -> nonce, expires
POST /login {"principal", "nonce", "signature"} -> token, expires, role
Every refusal names the call that works. core/actors.py::role_of (re-exported
by core/types.py, so types stays the one name a caller imports) is the
only actor parser in the codebase (v1 had five, plus an inference step that
let an actor-less sub-agent silently resolve to the human).
Identity is resolved per CALL, not per process (2026-08-07). Every MCP
tool accepts actor=, and board.py::_who lets it win over the board's own
identity (TASKOPS_ACTOR at MCP-server start, else the credential's
subject). The reason is the host, not taste: one MCP server runs per session
and every sub-agent shares it, so the export in a worker's brief never
reaches the process that speaks MCP — without a per-call actor, every worker
spoke as the orchestrator and take was unreachable. On a remote board
http/auth.py::authorize judges the override: a credential may act as
itself, and a dev's credential as that dev's own agents — nobody else's.
A tool is what an agent should think in one move; a verb is one write on the
board. The mapping is deliberately not 1:1 in either direction:
taskops_assign alone is two verb calls (assign, then per-card worktree
creation) collapsed into one tool; taskops_merge runs git in the caller's
filesystem before recording the result as a verb (v1's recover ran git on
the server and reported paths from a machine that was never the caller's);
taskops_review is the verifier's ONE door — task= claims the review lease
and returns the whole dossier, verdict= judges — because a review=true flag
on taskops_take was built and then deleted:
two tool surfaces over one verb is the duplicate-channel shape that broke v1,
and take holds the WORK lease and nothing else; and
taskops_update + taskops_comment are two tools over the ONE update
verb — one write path underneath, because v1 split closing across six modules
and forgot to record it in one of them, but two moves on top, because saying
something is not changing something.
taskops_activity and taskops_filed are the reports chapter's pair, and the
count of tools is asserted in tests/test_mcp.py so this sentence cannot rot.
taskops_activity is taskops_card for a whole chapter: the header once,
then a story per card — state, standing, commits with their numstat,
merged_into, the released note, the seconds. It is the one read with a
declared PAYLOAD BUDGET (verbs/activity.py carries the measurement): 76 cards
at the default depth=headline come back under ~100KB, which is paid for by
dropping the two unbounded prose fields — the spec and the thread — and sending
thread_total instead. depth=full asks for them. No diffs and no patch text,
ever: branch and the shas are the pointers a reader follows into its own
clone, exactly as the log stores a report's path and not its bytes.
taskops_filed is the write half of that same rule (§2's reports), and the
reason it exists as a tool at all: without it an agent can commit a narration
and has no way to put it on the board.
flowchart LR
subgraph Orchestrator
board_o["taskops_board"]
plan_t["taskops_plan"]
dispatch_t["taskops_assign"]
merge_t["taskops_merge"]
end
subgraph Worker
take_t["taskops_take"]
update_t["taskops_update"]
comment_t["taskops_comment"]
card_t["taskops_card"]
review_t["taskops_review — neither role's alone"]
activity_t["taskops_activity — neither role's alone"]
filed_t["taskops_filed — neither role's alone"]
end
plan_t -->|"writes cards"| verbP["verb: plan"]
dispatch_t -->|"assign + cut worktree"| verbA["verb: assign"] --> gitA["gitwork/trees.ensure_card"]
take_t -->|"acquire lease"| verbT["verb: take"] --> liveT["live.sqlite INSERT OR IGNORE"]
update_t -->|"close / release / drop / rewrite"| verbU["verb: update"]
comment_t -->|"say something on ANY card, closed included (+mentions)"| verbU
review_t -->|"task= claims · verdict= judges"| verbR["verb: review"] --> liveR["live.sqlite — the REVIEW lease"]
merge_t -->|"--no-ff in integration worktree"| gitM["gitwork/landing.merge_card"] --> verbM["verb: merged"]
activity_t -->|"a chapter's story, capped, no diffs"| verbAc["verb: activity — a READ"]
filed_t -->|"path + sha of a COMMITTED report"| verbF["verb: filed"]
flowchart LR
W["a verb call"] --> J["events.jsonl\nappend + fsync FIRST"]
J --> C["cache.sqlite\nadd(events)"]
C --> F["replay.fold()\nin-memory State"]
W -.->|"lease acquire/release/renew"| L["live.sqlite\nleases + presence"]
style J fill:#333,stroke:#888,color:#fff
style L fill:#333,stroke:#888,color:#fff
events.jsonlis the truth. Event ids aresha256(canonical)[:32], so a replayed write is a no-op — the log is idempotent by construction.cache.sqliteis disposable: delete it,Stores._bootstrap()replays the log and rebuilds it on next open.live.sqliteis not disposable and lives in its own file on purpose — a cache rebuild must never be able to drop a live claim (v1 kept both kinds of state in one database).- The write order is fixed: journal → index → fold. A crash between the first two costs a rebuild (automatic); the reverse order would cost the events themselves.
- Replay sorts strictly by
tswith Python's stable sort, so events sharing a timestamp keep arrival order — sorting by(ts, id)was tried and broke aclaimed/releasedpair under a frozen test clock.
sequenceDiagram
actor Dev as dev:berna (orchestrator)
actor W1 as agent:berna/w1 (worker)
participant Board as taskops board (MCP/HTTP)
participant Git as git (worktrees)
Dev->>Board: taskops_plan (milestone + cards + deps)
Board-->>Dev: cards created, status=open
Dev->>Board: taskops_assign tasks=[tk-a1]
Board->>Git: ensure_card() — new worktree, branch tk-a1
Board-->>Dev: paste-ready brief for tk-a1
Dev->>W1: spawn worker with the brief
W1->>Board: taskops_take task=tk-a1 actor=agent:berna/w1
Note over W1,Board: actor= on the CALL — the sub-agent shares the session's MCP server
Board->>Board: live.acquire() — INSERT OR IGNORE (the mutex)
Board-->>W1: whole card: spec, criteria, thread, collisions, worktree path
Note over W1: card is now `doing` — DERIVED, no row written
W1->>Git: commits in its worktree (branch tk-a1)
Git->>Board: post-commit hook → bind.record() (sha, subject, files, numstat +/- per file)
W1->>Board: taskops_update task=tk-a1 status=done note="…"
Board->>Board: check_transition — refuses if 0 commits and no_code≠true
Board-->>W1: ok
Note over W1: card is now `done` — assignee cleared, lease irrelevant
Dev->>Board: taskops_board
Board-->>Dev: group MERGE: [tk-a1]
Dev->>Board: taskops_merge task=tk-a1 (or tasks=[tk-a1, tk-b2] / done=true — the same path per card, in order, stopping at the first failure)
Board->>Git: trees.behind() — if tk-a1 lacks the ms/* head, catchup.catch_up() merges ms/* IN tk-a1's own worktree, with the chapter's union_files (from the dossier already loaded) applied as merge=union through an EPHEMERAL core.attributesFile outside the repo (dirty or missing: refused, untouched; conflict in anything undeclared: aborted clean, git's files + the count)
Board->>Git: merge_card() --no-ff into ms/<milestone>, in the integration worktree
Git-->>Board: sha (or refused, conflict files named, ms/* untouched)
Board-->>Dev: merged
Note over Dev: the HUMAN decides how ms/<milestone> reaches main: a PR, or taskops_merge milestone= once every card is closed and integrated
Dev->>Board: taskops_merge milestone=ms-x criteria_met=true
Board->>Board: the GATE first — open work, then the chapter's criteria (no answer, no git)
Board->>Git: trees.behind_trunk() — if ms/* lacks base_ref's head, the SAME catchup.catch_up() merges the trunk IN the integration worktree (dirty or missing: untouched, today's path; conflict: aborted clean, git's files + the count)
Board->>Git: land_milestone() --no-ff into the trunk, in the shared checkout
sequenceDiagram
actor W1 as agent:berna/w1
participant Live as live.sqlite
participant Board as taskops board
W1->>Board: taskops_take task=tk-a1
Board->>Live: acquire(tk-a1, w1, ttl=900s)
Note over W1: w1's process crashes. Nothing else happens.
Note over Live: no DELETE runs, no sweep, no cron — the row just sits there, unexpired
rect rgb(40,40,40)
Note over Live: 900 seconds pass
end
actor Dev as dev:berna
Dev->>Board: taskops_board
Board->>Live: held(now) — the row for tk-a1 has expires <= now
Board-->>Dev: tk-a1 is NOT in `doing`. It is in `stalled` (assignee=w1, quiet_for≥900s)
Note over Dev: no verb named "recover" exists to call
Dev->>Board: taskops_assign tasks=[tk-a1] (hand it to a new worker)
actor W2 as agent:berna/w2
W2->>Board: taskops_take task=tk-a1
Board->>Live: acquire() — old row past its expiry, new INSERT succeeds
Board-->>W2: the card, plus w1's handover note if it left one
Nothing here is a repair. stalled was always going to be the answer the
moment expires <= now; taskops_assign is the same verb used for a card
nobody has touched yet.
| transport | who uses it | shape |
|---|---|---|
| MCP over stdio | Claude / any MCP host, per-repo | newline-delimited JSON-RPC 2.0. initialize returns instructions: the whole role protocol AND the board as of that moment (mcp/hello.py — the same verb and renderer an agent would call, so it is a delivered answer, not a second version of one). That is what a v1 system prompt was, minus the second place for truth to live, and it needs no hook and no settings file to be trusted. One server per SESSION, shared by every sub-agent, which is why identity rides on the call (actor=, §5) and not in the process's environment. tools/call on a refusal answers isError: true with the refusal as the text content — never a protocol-level error, because an agent that cannot read the way out will invent one; an unexpected exception answers the same way rather than ending the loop, since that loop is the session's only door to the board. |
| HTTP RPC | a remote board's clients (RemoteBoard, the browser) |
POST /<board>/rpc with {"verb", "args", "actor"}, Authorization: Bearer <token>. Answer is always an envelope: {"ok": true, "seq": N, "data": {...}} or {"ok": false, "error": {"code", "message"}} — v1 let three verbs answer with a bare array and the decoder silently turned it into {}. |
| GitHub, from the OWNER's laptop | taskops board forge <owner>/<repo>, and nothing else in the system |
two GETs, neither of them at a taskops door: GET /repos/{owner}/{name}/collaborators?permission=<need> (paginated, the owner's own token in the Authorization header — the only authenticated call in the chapter) and GET https://github.com/<login>.keys (PUBLIC, text/plain, no credential spent). What reaches the host afterwards is members.enroll over the ordinary /rpc, carrying principals and ssh key lines: no GitHub token ever crosses to a taskops host, and none is stored on either side — it is a header on the owner's outgoing requests and dies with the command. A board that declared no forge is not asked about at all, so it stays invite-only exactly as before. There was a door here for one day — a dev POSTing their own token to /<board>/join/github — and §19.1 is why it is gone (cli/github.py, cli/team.py, http/members.py, and the tail block in tests/test_topology.py). |
| WebSocket feed | the browser UI only | GET /<board>/feed, upgraded via the RFC 6455 handshake (HTTP/1.1 required for the 101 response to be accepted — the one bug class invisible to a library-based test, caught only with a raw-socket test). A message is a signal ({"type": "changed"}), never a payload — the client refetches, so a dropped or duplicated frame can never show something the board never said. SSE is the automatic fallback for a proxy that eats the Upgrade. Agents never use this: their live channel is the "pulse" line appended to every tool result. The UI carries exactly ONE write — a comment box with a mention picker on the card panel, through the same /rpc door and token. |
| Delivery hook | Claude Code sessions in a joined repo | taskops hook claude, wired by init/join into .claude/settings.json on PostToolUse + UserPromptSubmit. Read-only and one-way: resolves the reader (env, else worktree-path → card → holder) and injects context — ✉ … when a mention is pending, for ANYBODY (throttled per reader per 30s); and ◆ …, one line per group with the count and the call that clears it, when MERGE / REVIEW / STALLED is non-empty and the reader is a dev: (throttled per 180s, its own key). Silence otherwise, exit 0 always, any failure silent. Both reads (mentions, waiting) renew no lease. The one sanctioned Claude hook: it may deliver, never decide, store, or write. |
| removed / never built | why | where it's enforced |
|---|---|---|
a recover verb |
doing is derived from the live lease; nothing is ever wrong to recover. Handing a card on is not that verb and never grew into one — it stayed inside assign, needs a named replacement, and is argued in §12 |
no entry in verbs/__init__.py::REGISTRY; tests/test_verbs.py::test_a_dead_workers_card_comes_back_by_itself |
| a reviewer ROLE, a stored review STATUS, automatic reviewer assignment | v1's review system: peer deadlocks, 14 closing rules over 6 modules, reviewers eating the budget of the work |
review EXISTS since 2026-08-07 but narrowed: optional per card, derived from history-only events + a second lease, judged by an ordinary agent that may never judge its own work. CARD_STATUSES stays three; there is no reviewer role and nothing auto-assigns |
| AUTOMATIC merges to the trunk | v1's land merged as a side effect of closing a card and ran checkout under working agents |
a CARD cannot be merged to the trunk — taskops_merge task= takes no target. A finished MILESTONE lands via taskops_merge milestone= (2026-08-07): explicit, refused while any card is open or unintegrated, refused off-trunk, recorded as a milestone landed event. What stays impossible is the trunk moving as a side effect of anything |
| git replication between clones | split-brain, two machines "owning" the same card | RemoteBoard never falls back to a local store on write failure (Unreachable instead). Narrowed on 2026-08-31, not lifted: the host became the board's git REMOTE (§16, "The host becomes the remote") — a hub, one direction on each leg (worktree → host, host → forge). What stays banned is BETWEEN clones: no clone pushes to another clone, and the host never pulls git from the forge |
| Claude hooks that decide or store | latency, another thing to install and drift; v1's held state and gated actions | context travels in initialize.instructions + tool responses. The ONE exception, sanctioned 2026-08-06: taskops hook claude, delivery-only — it reads, injects a ✉ or ◆ line, and can be deleted with no loss but immediacy. tests/test_claude.py pins its safety properties, one test each — the module docstring lists them and grep -c '^def test_' tests/test_claude.py counts them |
| a mark-as-read / ack verb for mentions | a stored read flag is recover again: a write whose only job is to contradict an earlier one |
core/mentions.py::pending() derives it from the thread; tests/test_verbs.py::test_a_mention_clears_itself_the_moment_the_actor_touches_the_card |
| PER-REQUEST SIGNING (every call carrying an SSHSIG envelope, no sessions) | it was the first design and sessions won on cost, not on taste: a signature per request means teaching a signature envelope to THREE transports — the stdlib http.server, the WebSocket handshake on /feed, and the MCP layer that opens a board once and holds it — and every one of them would have had to learn nonce replay, clock skew and canonicalisation separately, for exactly the result a bearer already has. It also needs the private key FILE on every call, in every worker, forever; a session needs it twice a day. And the fleet argument decides it: a signed envelope is a NEW wire format, so production's four boards would have had to be re-joined, which rule 3 forbids |
/login is the only door that verifies a signature (http/login.py), and what it hands back is an ordinary Credential — the same row, the same table, the same Authorization: Bearer every legacy token uses (store/creds.py). The client half is session.py (the token's life: mint, refresh, remember) with identity.py beside it (WHO signs in and with WHICH key — discover_key, establish, the one door --key comes in through); each is well under the budget because each has one job. tests/test_topology.py::test_a_legacy_only_board_still_works_on_rpc and its three siblings are the fleet half: production's exact state — no principal, no key, an empty allowed_signers — driven through /rpc, /feed, the MCP handshake and the taskops ui window |
| HAND-ROLLED CRYPTO, and a pip crypto DEPENDENCY | two ways to lose the same argument. Writing ed25519 verification by hand is the classic own-goal; adding cryptography or PyNaCl to buy it back breaks "a wheel and a directory" — the property that makes pip install taskops on a bare box a deploy (§17) — and puts a compiled wheel in the path of every agent's install |
the verifier is OpenSSH's own: ssh-keygen -Y sign / -Y verify -f allowed_signers, the same SSHSIG mechanism git uses to sign commits, invoked through the ONE subprocess module (gitwork/sig.py, under gitwork/run.py). pyproject.toml has no runtime dependency at all, and tests/test_architecture.py keeps subprocess out of every layer but that one |
| ANONYMOUS WRITES, in any form — including the invisible one | a public board is anonymous READ and nothing else. The subtle failure is not a card somebody could see: every read verb opens with stores.live.renew(actor, now), an INSERT into presence, so a public board without a guard has every visitor writing to live.sqlite on every page load — no event, no card, nothing any ordinary test would notice. There is also no "anonymous-write grace" and no third visibility. A git push to the host's repo.git is a write under this ban, exactly as an event is (§16, "The host becomes the remote") |
http/auth.py::anonymous never hands out a credential with more than {"read"}, and refuses a write with the sentence that names how a key gets registered; store/live.py::renew is the ONE place that decides the presence row. tests/test_topology.py::test_an_anonymous_crawl_of_a_public_board_moves_not_one_byte asserts events.jsonl and live.sqlite (with -wal and -shm) hash-identical across a crawl of every read door there is, and …::test_anonymous_may_not_claim_to_be_somebody closes the actor= hole |
a --force on taskops board push, and a SYNC channel behind board.ingest |
a non-empty target means two histories, and giving them an order they never had fabricates a timeline the board never observed. The precondition ("no history but its own beginning") is true exactly once in a board's life, which is what keeps the door a promotion and not replication between clones — the thing banned two rows up | http/ingest.py refuses and says so; tests/test_topology.py::test_a_target_that_is_not_empty_is_refused_and_no_force_is_offered, and the argument is the module's own docstring |
a --force on taskops board rm (and it is not an alias for --discard-history) |
removing a board deletes the only copy of a history nobody can regenerate, and --force is a word every tool spends on something recoverable — it names no consequence, so it cannot warn. The flag that gets past the guardrail names the thing it destroys, and the guardrail itself is on the HOST, comparing the ids the caller says it holds against the ids the board really has: a wall the client enforces is a convention |
http/removal.py refuses unless core/holding.py says the history is held elsewhere, and only a literal true opens discard_history; tests/test_topology.py::test_the_flag_is_named_for_what_it_destroys_and_a_force_is_not_a_synonym and …::test_board_rm_refuses_a_history_this_checkout_does_not_hold_and_names_both_ways_out |
a --force push, a ref deletion, or a prune against the host's repo.git — and a HAND-ROLLED git protocol behind it |
the host became the board's git remote (§16, "The host becomes the remote", 2026-08-31) precisely because a forge-side prune erased a landed card's diff from the board; a force or delete on the host would reintroduce the same erasure at the hub, and there is no flag that opens either, for board rm's reason — --force names no consequence. And the pack protocol is git's own, spoken by git's plumbing through the one subprocess module, for the reason hand-rolled crypto is banned two rows up: a Python pkt-line is the same own-goal with a different file extension |
gitwork/bare.py writes receive.denyDeletes and receive.denyNonFastForwards into the created repo's own config, so the refusing process IS the git receive-pack that would move the ref (tests/test_topology.py::test_the_host_refuses_a_ref_deletion and …_a_non_fast_forward_even_forced pin both, --force included); subprocess stays confined to gitwork/run.py by tests/test_architecture.py |
a SILENT taskops join over a local board |
the local history stayed on disk byte for byte and nothing ever looked at it again or said so — a command about connecting is what made it invisible | cli/commands.py::_keep_or_archive refuses naming taskops board push and --discard-local (which archives, never deletes); tests/test_topology.py::test_join_refuses_to_orphan_a_local_board_and_names_both_ways_out |
| GitHub as a CREDENTIAL — v1's GitHub login: a stored token, a call to GitHub on every sign-in | three costs, and §19 is the whole argument: a token that travels (worth stealing for reasons unrelated to the board), a network dependency at every login (GitHub down = nobody signs in), and a SECOND identity system beside the keys, with its own enrolment, expiry and revocation to keep in step. The convenience is kept and the costs are not: GitHub is asked ONCE, by the owner, and what persists is an ssh key | the only module that speaks to GitHub is cli/github.py, on the owner's machine; the host takes principals and key lines (http/members.py) and no token reaches it. tests/test_topology.py::test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere greps the host tree AND the checkout with a positive control, and …::test_no_flag_on_join_takes_a_token_and_none_ever_will holds the parser to "never a flag value". There is one members.enroll row in core/scope.py — a ROLE rule — and no second credential type anywhere |
a GitHub door on the DEV's side — POST /<board>/join/github, a --github flag, a token discovered at join time (2026-08-11) |
it made every dev's own credential travel to a host that has no business seeing one, to prove a fact the OWNER already holds; and it asked for an ssh key from people who, having push over ssh, had published one already. Deleted the day after it shipped — §19.1 | there is no route, no flag and no client function: grep -r 'join/github|by_github' src tests is empty, and test_no_flag_on_join_takes_a_token_and_none_ever_will asserts the ABSENCE from join --help. The replacement is one command on the owner's side and taskops join, bare, on the dev's (…::test_the_dev_whose_key_the_sync_published_joins_with_two_words) |
a COMMITTED .gitattributes (or a written $GIT_DIR/info/attributes) as the union-merge mechanism |
a milestone's union_files is one chapter's convenience, and both of those are repo-wide and outlive the merge. Measured in this repo: git rev-parse --git-path info/attributes inside a LINKED worktree answers the COMMON dir, so writing there hands one card's declaration to every sibling merging at the same moment, and a crash between write and delete leaves it enabled for the whole repository, invisibly. A committed file is worse still: it makes the chapter's convenience permanent and reviewable as if it were a repo decision |
gitwork/catchup.py::_attributes writes a temp file OUTSIDE the repository and passes it as git -c core.attributesFile=… for that one process, deleted in a finally on both paths. The precedence is deliberate: an in-tree .gitattributes BEATS core.attributesFile, so the dashboard bundle's -merge cannot be overridden by a declaration. tests/test_git.py::test_no_attributes_file_survives_a_merge_that_worked and …_that_aborted assert nothing is left in the temp dir, in the tree, or at info/attributes |
| a slug in a branch name that isn't the milestone's | a renamed milestone orphaned its branch (ghost branches) | Milestone.branch computed once at creation, stored, never re-derived |
a stored doing |
a dead worker's card claimed to be worked on forever | CARD_STATUSES = ("open", "done", "dropped") — "doing" raises BadRequest if ever passed to status= |
| a worker-SLOT roster (held / free / lapsed as a pool) | taskops allocates no worker: workers=[…] is a label chosen at the call, sub-agents are ephemeral, and an actor is a name bound to the RUN of a card — a roster with capacity would be a fiction the board could never make true |
the Actors view draws DEVS with their agents as lines inside them instead (ui/src/pages/Actors.tsx, components/monitor/panels.ts — both carry the post-mortem); an agent with no card is HISTORY, never "free", and ui/smoke/sections/actors.tsx asserts the words free, slot and capacity appear nowhere in that markup ("actors: NO WORKER SLOTS, under that name or any other") |
a report's CONTENT in events.jsonl, and a reports TABLE beside it |
the log is replayed forever, so a 200KB narration in it is 200KB paid on every rebuild of every cache, for a string nothing folds. And a stored list is a second fact somebody has to keep in step with the events that produced it — this project's oldest bug, the one core/mentions.py carries the long version of |
the report event body is {path, title, milestone, sha} and Kind(replayed=False), so replay never folds it into state; the list is core/reports.py::of() on every read. tests/test_verbs.py::test_the_log_grows_by_a_pointer_and_never_by_the_report measures events.jsonl across a filed call and asserts under 1024 bytes, whatever the report's size |
allow-scripts beside allow-same-origin on the report frame, and a sandbox the caller can pass |
not two permissions but the absence of the sandbox: with both, the frame reads parent.localStorage — where this dashboard's token is — writes parent.document and strips its own sandbox attribute from the inside. A sandbox prop is the same hole with a longer fuse: one caller widens it and the boundary is gone with nothing red |
SANDBOX is a module CONSTANT in ui/src/components/reports/ReportFrame.tsx (§15), there is no dangerouslySetInnerHTML in the dashboard, and a text/plain report is never framed at all. Pinned at both levels: ui/smoke/sections/report-sandbox.tsx renders a report that really tries all three attacks (tests/test_ui.py::A_HOSTILE_REPORT) and asserts the parent document carries no <script of it, and tests/test_ui.py::test_the_committed_bundle_carries_the_dashboard asserts the pair is absent from the SHIPPED bytes — which is what would notice a rebuild from a widened tree |
| a TILE per ephemeral sub-agent | the first Actors page drew one per ACTOR: sixty-seven tiles, sixty-six of them processes that had already died with their cards, each wearing the human's layout — the page contradicted its own goal | the top level is the DEV (devRows()), an agent is a row inside it, and ui/smoke/sections/actors.tsx pins that a board with one dev and many agents draws exactly one card per dev and none per agent ("actors: one card per dev is DRAWN, and no card for an agent") |
The force_release() this section used to flag as dead code is deleted:
no verb ever called it, and the only thing keeping it alive was a test whose
name (…_but_recover_always_can) advertised a recovery path this board does
not have. A dead function that describes a banned design is worse than no
function — the next reader takes it as permission. An abandoned lease expires;
nothing takes a card away from its holder.
Amended 2026-08-11, and the amendment is narrow. One thing does take a card
from its holder, and it is not a recover: taskops_assign handing that card to
a NAMED replacement (store/handover.py::displace, its only caller
verbs/assign.py). What made force_release() a banned shape was that it freed
a lease into nobody's hands, on nobody's authority, to fix something the design
says is never wrong. This frees it into a named worker's, on the orchestrator's,
in the same call and the same event that names them — and the reason is that the
clock was answering a question it cannot answer. The lease's only heartbeat is
Live.renew, called by every verb, so MCP traffic stands in for alive, and
that proxy is wrong in both directions at once: a worker that DIED holds its card
for up to LEASE_TTL while the orchestrator that watched it die is refused the
hand-over, and a worker that is WORKING — twenty quiet minutes of reading,
editing and running tests, not one MCP call — stops renewing and is reported
stalled while alive. No value of LEASE_TTL fixes both; raising it worsens the
first and lowering it worsens the second. They are one bug pulling in opposite
directions. So the clock stopped being the authority: stalled is now a
report ("quiet for N minutes", quiet_for), never a mechanism, and what
changes hands is decided by somebody, on the record. Nothing is taken by the
passage of time, and a displaced worker still does not lose what it built —
core/machine.py::_not_somebody_elses asks about the ASSIGNEE, never the clock.
Pinned by tests/test_verbs.py::test_the_orchestrator_hands_over_a_card_whose_lease_is_still_live
and …::test_handing_a_card_to_the_worker_that_holds_it_leaves_its_lease_alone.
So a lapsed lease never blocks the owner's own close (2026-08-14, asked by an
operator who watched a resumed worker's done land after its lease had expired).
It is accepted, deliberately: core/machine.py::_not_somebody_elses refuses only
a LIVE holder who is somebody else, so with no new holder the worker that did the
work still closes it — the same argument as above, read from the other end. A
close that demanded a live lease would punish exactly the twenty quiet minutes of
editing that the clock cannot see, and it would do it at the one moment the work
is finished. Pinned by
tests/test_core.py::test_a_lapsed_lease_does_not_cost_you_your_own_card.
And a done card can never read stalled. The same operator reported seeing
both at once. That is not a state the derivation can produce:
core/graph.py::derived answers the STORED status first, above every live fact,
so the instant the done event is in the log the card reads done — owner or no
owner, holder or no holder, however long the lease has been gone. stalled
belongs to the open branch alone. A stalled row beside a landed done is a read
that happened before the write (or a different copy of the board), never a
disagreement between the two. Pinned by
tests/test_core.py::test_a_closed_card_derives_done_and_can_never_read_stalled.
And a report has to be readable. quiet_for alone was not: a session limit
took five workers at once and every STALLED row said the same "quiet for 1h", so
resume-vs-reassign became an investigation per card. A stalled row now also
carries last_event = {kind, ago} — the last thing on the card's thread — and
commits, how many commit events are bound to it (verbs/_rows.py::forensics,
attached by pulse.py::run to the stalled group ALONE, exactly as waiting_on
is attached to blocked; every other row is byte-identical to what it was, and no
other row pays for the thread read). Dispatched-and-never-heard-from
(edited), claimed-then-silent (claimed), thinking-out-loud-then-gone
(comment) and work-on-the-branch (commit, with a count) are four different
moves, and they are now four different lines. Still derived per read, still
nothing stored, and still no cause of death: the lease's only heartbeat is MCP
traffic, so the board can say WHAT was last said and never WHY it stopped.
Rendered on the MCP board by mcp/boardview.py::_group; pinned by
tests/test_verbs.py::test_a_stalled_row_says_what_its_holder_last_did_and_how_long_ago,
…::test_a_stalled_row_counts_the_commits_bound_to_the_card,
…::test_only_a_stalled_row_carries_the_forensics_keys and
tests/test_mcp.py::test_a_stalled_line_says_what_the_holder_last_did_and_what_is_on_the_branch.
The remaining "recover" mentions (core/types.py, verbs/assign.py,
mcp/tools.py, gitwork/run.py, core/mentions.py, store/live.py) are
intentional: each one explains why the
rule exists, which is the whole convention of this codebase.
A known limit of collisions(), argued but not changed.
verbs/_context.py::collisions intersects the DECLARED files of open cards:
it lives in path space. The Nova UI milestone (2026-08-07) fanned four cards
onto pairwise-disjoint paths — the warning was correctly silent — and the
merged tree still came back with two ago() and three initials(), because
that class of failure lives in symbol space. The conclusion of that
post-mortem is deliberately conservative and belongs here: taskops does NOT
learn to parse source (a symbol scanner is the first component that would have
to know what a language is, and §14's layering has nowhere to put it);
collisions() is not widened; language-specific duplicate detection stays in
the project's own linter. What it recommends instead is free — land the seams
serialized before fanning out — plus one optional field, criteria on a
Milestone, next to rules. Both are implemented (tk-097cae, 2026-08-07): the
ordering rule is one sentence in mcp/server.py::INSTRUCTIONS, delivered at
the handshake inside hello.py's budget; Milestone.criteria travels into
every take like rules and is SHOWN at taskops_merge milestone=, which
refuses until the human answers — criteria_met=true, or criteria_met=false
with a MANDATORY note= (2026-08-14, tk-d65ad3: some criteria are structurally
post-landing — "seven days of live rows" for code that only deploys FROM the
trunk — and a gate that took only true deadlocked the chapter or invited a
lie). Both answers, and the note, are recorded in the landed event and never
judged by the machine; false in silence is the one refused outcome, in the
gate (mcp/chapter.py::land, before any git runs) and again in the write
(verbs/record.py::merged). §10 of that post-mortem was the map from each
adoption to its test.
And the seam files a chapter declares are a chapter's field, not a repo's
(2026-08-14, tk-6882a1). Three conflicts in one real wave were the same shape —
sibling cards each APPENDING to a registry, a changelog, a barrel file — and git
already folds that shape with its built-in union driver. So a milestone
DECLARES the paths: taskops_plan union_files=[…] at creation, or
taskops_update milestone=… union_files=[…] afterwards, replaced WHOLE like
rules and criteria (verbs/_chapter.py::LISTS, core/chapters.py) — the
un-declare is union_files=[], because an append-only log has no un-declare
event. It is SCOPED twice over. In space: only the declared paths get
merge=union; everything undeclared conflicts, aborts and refuses byte for
byte as before, and an in-tree .gitattributes still WINS over the ephemeral
file, so the dashboard bundle's -merge cannot be overridden by a declaration.
In time and in place: it is applied on the CARD's catch-up alone
(mcp/integrate.py reads it off the dossier it already loaded and passes it to
gitwork/catchup.py::catch_up); the chapter→trunk catch-up
(mcp/chapter.py::_catch_up_to_trunk) passes nothing, because a chapter meeting
a moved trunk is the human's merge, not a sibling's append. Why the mechanism is
an EPHEMERAL core.attributesFile outside the repo rather than a committed
.gitattributes or info/attributes is argued in §11's table — in one line,
both of those are repo-wide and outlive the merge, and inside a linked worktree
info/attributes resolves to the COMMON dir, which would hand one card's
declaration to every sibling merging at that moment.
the suite green uv run pytest
ruff + pyright clean } uv run ruff check src tests · uv run pyright
pyright --strict clean
That number moves with every card; re-run the command rather than reading it here.
Test categories: architecture (AST-enforced layering), core (pure functions:
replay, graph, machine, hours, mentions, review), store (log/cache/live),
verbs (including the whole review cycle and the two races it can lose),
gitwork (including a raw-socket WebSocket handshake test), http (topology),
mcp (dossier section order, tool refusals), migration (the v1 mapping),
ui (headless: ui/smoke/run.mjs renders the modules src/main.tsx bundles
through react-dom/server — no browser, no jsdom — against a payload a real
LocalBoard answered with, plus a read of the COMMITTED bundle. It skips
without node or ui/node_modules).
Both remaining items are done. The migration ran against all four v1 boards
(§17), and taskops.bernardocastro.dev serves v2 since 2026-08-08 — not via
shipway, which moves a code tree to a pm2 app: a board host is a wheel plus a
directory of board logs, so it is pip install into a venv and one pm2 entry
(README, "Deployed"). The box runs this tree since 2026-08-09 (tk-df8e64,
§17), so https://taskops.bernardocastro.dev/<board>/ui/ answers 410 and one
sentence, exactly as the trunk does (§16, §17) — until a board with a declared
forge gets its hosted window (§16, "The hosted window"). Re-derivable:
curl -s https://taskops.bernardocastro.dev/healthz and
curl -w '%{http_code}' https://taskops.bernardocastro.dev/axion/ui/
— measured {"boards": 4} (after all four boards had been addressed through
/rpc) and 410 with http/static.py::NO_UI as the body, on 2026-08-09. The repo is under git and its history is real:
the board's own milestones land on master through taskops_merge milestone=.
Pinned by tests/test_architecture.py, which reads the AST of every module.
A rule with no test is a suggestion, so each of these has one. Every entry
was a v1 bug that cost real time; the test's docstring names it.
| rule | why | where |
|---|---|---|
| imports only point DOWN, no exceptions | a cycle between layers is how v1 came to re-decide routing at 25 call sites, four of them differently | test_imports_only_point_down |
mcp/ and http/ never import each other |
peers go through board/verbs, or the two drift |
test_transports_do_not_import_each_other |
sqlite3 only under store/ |
one place knows SQL exists | test_sqlite_only_in_store |
subprocess only in gitwork/run.py |
v1 had four wrappers; one swallowed stderr and reported a refused push as "somebody landed while this ran", in an infinite retry loop | test_subprocess_only_in_gitwork_run |
the clock only in _clock.py + core/hours.py |
v1 let a stray strftime through and a report cut days in two timezones at once |
test_only_clock_reads_the_clock |
core/ imports no I/O at all — not even os |
level 1 is pure or it is not level 1 | test_core_is_pure |
| a verb never runs git, renders, or reaches the network | v1's recover ran git porcelain on the SERVER and reported paths from a machine that was not the caller's |
test_verbs_never_run_git_or_render |
| ≤200 lines per module | v1's ≤70 produced artificial splits — land ended in six modules and the step that recorded the result was forgotten in exactly one of two entry points. 200 forces cohesion instead of fabricating files |
test_no_module_exceeds_the_line_budget |
no assert for an invariant in src/ |
python -O deletes them |
test_no_assert_for_invariants_in_src |
from __future__ import annotations everywhere |
ruff FA102 |
test_future_annotations_everywhere |
Four more that live in the code rather than the AST:
- A stored name is never re-derived.
Milestone.branchandLease.branchare computed once, at creation, and read from where they are. There is nobranch_for()recomputing from a mutable title — that was v1's ghost branches and the whole_whichbranchsaga. - No magic input coercion.
verbs/_args.pyrefuses a wrong shape and shows the right one. v1 accepted"true", CSV-or-list and JSON-in-a-string in fifteen places, and one of them readclaim="false"asTrue. - One error tree. Everything that escapes descends from
TaskopsError; a foreign exception is converted at the boundary that raises it. A caller never seessqlite3.OperationalError. - A refusal contains the call that fixes it, verbatim. The best habit of v1, kept word for word: an agent that reads "refused" and cannot see the way out will invent one.
Two habits, for whoever works here next:
- Mutation-check every fix. Break it on purpose, watch the test fail, put it back. Two tests in this repo looked green with the fix removed until this was done.
- Docs are part of the diff. This file,
README.mdandCLAUDE.mddescribe what is true NOW — counts, "not yet" and status tables all expire, and a statement that can be re-derived by a command beats a number that rots in silence.
A React + TypeScript app, source in ui/, built with esbuild by node build.mjs into src/taskops/ui/{index.html,app.js,style.css} — and that
output is committed, which is what lets pip install taskops serve a
dashboard on a machine with no node. React is bundled, never fetched from a
CDN, so it also serves offline. It is a client of the same HTTP contracts
described in §10 and imports nothing from the Python tree.
Its live channel (ui/src/client.ts::subscribe) is one reconnect loop with no
terminal state but stop(). It had two dead ends and neither survives: a
WebSocket that had opened and dropped retried exactly ONCE after 500ms and then
fell back to SSE permanently, and the SSE error handler only reported
onLive(false) — so an EventSource that died fatally (readyState CLOSED, a
non-200 while the server restarts) was retried by nothing at all, and the header
sat on "offline" with a stale board until somebody pressed reload. Now WS retries
with capped exponential backoff (500ms doubling to 8s: a laptop that slept
through the night must not wait minutes after the lid opens), a fatally-closed
SSE hands itself back to the same loop, and every failure path ends in retry()
or in a transport that will. Regaining the feed pokes exactly ONE refetch — the
transports' own hello frame, counted by frame(), never a second signal on
open — so staleness heals with the connection and one recovery is one fetch.
subscribe takes an injectable Env (WebSocket, EventSource, setTimeout,
clearTimeout), which is what lets ui/smoke/sections/feed-reconnect.tsx drive
the whole state machine headlessly, both removed dead ends included.
What it shows, at this commit — the section is written to be re-read against
ui/src/App.tsx and ui/src/components/chrome/TabNav.tsx, which are the two
files that answer it:
-
Monitor — Nova's first and central section and the default tab. The two-column layout (
ui/src/pages/Monitor.tsx) and the shared pane chrome (components/monitor/Pane.tsx) landed FIRST, with every panel's props declared as an interface incomponents/monitor/panels.ts— the seam serialized ahead of the fan-out — the prescription of the post-mortem that the same milestone produced. Nova drew eight<section>panes in six layout slots (three stacked blocks left, three panes right); the ninth is Swarm (components/monitor/Swarm.tsx), who is attached to what right now — the team'sdev:at the centre, a circle per actor and per card on one ring, an edge per LEASE (work and review are two mutexes, so a card under review has two edges and the verifier is its own kind), a faint edge for a stalled assignment, and a dashed one between two cards that DECLARED a path in common. A lease edge starts at the AGENT, never at its dev: the pane once drews14 — dev:berna — tk-13d115, the lease line crossing the centre, which is the one attachment the server makes impossible (verbs/__init__.pyrefusestaketo adev:). A dev reaches a card only through its agents, so the diagram carries a second KIND of edge —owns, dev → agent, derived from the name alone (format.ts::ownerOf, the relationhttp/auth.py::authorizeenforces on the wire), drawn--hair-2and thinner and left out of the header count, because a standing fact about a name is not work in flight. An agent whose dev is not onteamstands alone; no orchestrator is invented for it. The DRAWING is the mockup's SVG transcribed number for number — a 600×400 viewBox rendered 440 tall, guide rings at r=100 and r=130 with the nodes on the outer one, r=26 discs with a halo RING at r=34, two texts per node (the glyphs inside, the sub-label atdy=46) and a 28px grid as a CSS background on the wrapper rather than an SVG<pattern>; every one of those numbers turns a smoke assertion red on its own. Its placement istopology(), a pure function deterministic by index —Math.random()and a force simulation are both absent on purpose, because an animation loop is what no headless harness can assert. It reads only slices the board already sends: no verb, no payload key, no second fetch. All nine are filled, one card per panel — the Event stream among them, which drew an honest empty state for a chapter because nothing returned the log ("layout first, data second"), and is now fed by theeventsverb. That pane is the ONE place the dashboard fetches outsideuseBoard, and deliberately so: the log is a scrollback, not a snapshot, so it is paged by keyset onseq(store/cache.py::page,verbs/events.py) by the pane's own container (EventStreamPanebeside the pureEventStream,ui/src/useEvents.ts). It opens no second socket — the board payload's identity is its change signal, so a frame on the one feed resets it to page one. Chapter in focus carries the pane-level version of the same "an honest empty state, never an apology" rule (components/monitor/Chapter.tsx):verbs/_facts.py::in_scopereturnsNonefor SEVERAL open chapters as well as for none — it refuses to guess between them, and that refusal stays — but the pane used to read the refusal as a fault and drew a paragraph telling the reader to land or drop one, saying nothing about either. It now LISTS every open chapter, one foldable row each (a real<button>witharia-expanded), the first expanded, the body identical to the single-chapter pane's because it IS that component. An accordion and not tabs: choosing a chapter is the header picker's job, and a second control that selected would be a second source of one fact with the unchosen entry hidden. Each row'sfocustherefore calls the picker's OWN setter (App.tsx::setMilestone, threaded down) — a door, not a copy. Landed chapters are not listed; per-chapter counts are folded fromboard.groupsand are drawn nowhere if no row names a chapter. -
Board — the board's own groups, folded into SIX columns (
ui/src/pages/Board.tsx): Ready · In flight (doing+stalled, which carries a danger marker) · Review (review+reviewing+changes, three chips in one column) · Blocked · To merge · Done. When the focused chapter is COMPLETE — landed, or every group but Done empty with something done — the columns give way to the chapter story (ui/src/components/story/ChapterStory.tsx): a single landing-timeline, one tile per card in the order they landed, each with its closing line, diff-stat and worked time, under a header of aggregate stats. Completion is DERIVED per read from the board payload (ui/src/components/story/stats.ts) — no new verb, no stored status — the data is the existingactivityverb throughuseBoard, and the view is read-only like the columns it replaces: a tile opens the same card drawer. An open chapter is read two ways, chosen by acolumns | flowsegmented control (components/board/ViewToggle.tsx) whose state lives on the page and inlocalStorage. Not a route and not a sixth tab: the flow IS the board drawn differently, and a tab would file it beside Monitor and Actors as another page with another payload. Flow draws the dependency graph left to right, agents on the nodes; all of its geometry is decided without a DOM incomponents/flow/layout.ts, andFlowView.tsxmeasures and paints and decides nothing. Every edge it can draw ends on a BLOCKED node, which is the payload read honestly rather than a shortcut: aBoardRow(verbs/pulse.py::_row) carries noafter— the card's whole dependency list is sent only on the CARD read — and the one dependency fact on the board payload iswaiting_on, whichpulse.py::runattaches to the blocked group alone fromcore/graph.py::blockers, already filtered to the dependencies that have NOT closed. That is "closing a blocker frees its dependents by definition" reaching the drawing. The bands therefore cannot come from the edges — a done card with no surviving edge would sit beside a running one — so each state has a FLOOR (done 0 · in flight 1 · waiting 2) that a longest-path over open blockers may raise and never lower. A blocker the payload does not carry as a row yields no edge: a line to a node that is not on screen is a line to nowhere. In BOTH views a tile that changes column moves rather than teleporting (components/board/flip.ts+useFlip.ts): First/Last/Invert/Play, onerequestAnimationFrame, CSS transitions and no animation library. The old rects are cached at the END of every commit rather than measured on the way in, because the payload arrives throughuseBoardseveral components up and a refetch is not the only thing that re-lays the page out. Exits are NOT choreographed — a tile the board no longer says exists would be a second source of truth about what is on the board, for a quarter second of decoration — andprefers-reduced-motionskips the play, never the layout, which is exactly the behaviour that came before. The holder rides along:shared/Avatar.tsxis now the ONE disc, serving both the header presence row and the card tile, which disagreed before (four hash tones in the header, accent-vs-grey by role on the tile — one agent, two colours, which reads as two agents). It has two independent axes: the HUE is derived from the actor string over 24 steps because this board runs eight agents in a wave and four tones is a guaranteed collision, andlive— the lease actually held — is the emphasis. Neither is a literal colour: saturation, lightness and the wash's alpha are--disc-*tokens declared once per theme. -
Actors — the fourth view (
ui/src/pages/Actors.tsx), and the one that answers "who has been on this board, what did they carry, and for how long". It is a page about DEVS, and an agent is a LINE inside one. It shipped once as a grid of ACTOR tiles and that was sixty-seven tiles on the session that built it — one human and sixty-six ephemeral sub-agents that had already died with their cards — which contradicts the chapter's own goal: an actor is a name bound to the RUN of a card, andw1today is notw1yesterday. The top level is now the DEV, the durable identity (core/actors.py::role_of; anagent:<dev>/<name>carries its dev in the name,format.ts::ownerOf, the same relationhttp/auth.py::authorizeenforces on the wire), and TWO devs are two cards each with its own agents — the case the shape exists for, and the reason the top level is not "the current user". A dev card says how many of its agents are on a card RIGHT NOW without being opened, its figures over the window, the most recent few agent names and the rest as a count — never a list of sixty-eight ids.devRows()andactorRows()are exported pure precisely so the folding and the ordering are asserted without rendering. It reads no verb and no new payload key:team,doing,reviewing,stalledandboard.hoursare four slices the board already sends, andclosed/commitsare counts over the SAME window, said once in the subtitle. Which window is a CHOICE, and it opens on the calendar month — 7 days · This month · Last month · Total, aSegmentedfilter in the header (ui/src/hoursWindow.ts), carried into the one board call aswindow=and into the dev overlay unchanged. A sliding default is what read as "hours being discounted when a chapter closes"; §21 has the whole diagnosis. The subtitle says what came BACK, not what was asked:windowSaid()printsreport.window.label— the server's own "August 2026" — and falls back to the day-bucket sentence for a board that sends nowindowat all. A figure the payload cannot say draws an em dash and never0, and a dev's totals are dev + agents — refused WHOLE when one member cannot say its own, because a sum over a subset presented as a total is the dishonesty this chapter removes. Nova draws this screen with a WORKER SLOTS roster — held / free / lapsed as a pool with a capacity — and that panel is deliberately not built (§11): an agent with no card is HISTORY, saying what it carried and when it was last seen, never— free —. Nothing stands where the roster would have stood. An Hours worked today panel of BARS did, for one wave, and it is deleted rather than restyled: a bar chart exists to compare things against each other, and the things it compared were ephemeral agents — two labels. The hours are still drawn, and drawn better, inside the dev's own panel. A dev opens into a full overlay (components/actors/DevPanel.tsx), which REUSESshared/Overlay— the same portal, the same scrim, the same ONEoverlayStackthat owns Escape; the only thing it asked for is awidth.DevDetailis exported besideDevPanelfor the reasonDossieris exported besideDrawer: a portal renders nothing underreact-dom/server, and the document must still be readable headlessly. The earlier "it reveals in place, it is not a modal" is reversed — that reasoning was about routing, and what is on screen does not fit in a grid cell. Inside it,components/actors/Daysheet.tsxis a pane per calendar DAY, newest FIRST and only the newest open, with the day's counted total on its header; inside a day, one row per hour it actually SPANS — first session's hour to the last's, so the shape of the day is visible and never 00–23 — each row folding open to that hour's sessions:HH:MM – HH:MM, the duration, the card and its title, each a door to the card's dossier. This is the SECOND design of that panel. The first drew ONE LANE PER AGENT on a shared wall-clock axis with a table of per-agent rows under it, and every trace of it is gone (tests/test_ui.py::RETIRED_TIMESHEETasserts the bundle carries none of its markers): an agent is a name bound to the RUN of a card,w1today is notw1yesterday, so a lane per agent compared two labels. A sub-agent adds nothing on this screen; it is at most the name on a session. Two rules carry the arithmetic. A session belongs to the hour its START falls in and is never split — splitting would invent intervalscore/hours.py::sessionsnever produced, and that list is the very onespent()folds into the totals, so screen and figure cannot drift. And an hour inside the span with nothing counted is DRAWN, sayingnothing counted, because that is where the dropped gaps are: a gap over 30 minutes is dropped WHOLE, never capped (v1 capped it and every break added a phantom half hour), which is why a day's total is smaller than its last session minus its first. The gaps are counted and measured under the hours, and the rule is on screen incore/hours.py's own words. Every fold is a real<button>witharia-expanded; an hour with nothing behind it has no arrow at all, because an arrow that opens nothing is not a control. The fold state is view state and remembers nothing. -
Worktrees — an INDEX OF PULL REQUESTS (
ui/src/pages/Worktrees.tsx): one tile per inhabited directory, which is idea 2 made visible, in two 50/50 columns — In progress and Merged — each split into two sub-blocks, and a row carries the four facts you choose one by (branch, title, who carries it, which chapter). It was a five-column table whose commit cell had no source; that cell and itsworktree-commitsmarker are gone. A tree is a pull request, so a row opens THIS view's own full-width diff surface (WorktreeDiff, its props declared incomponents/monitor/panels.tsasWorktreeDiffProps) and never the card drawer: the index is replaced, not floated over. The selection lives inApp.tsx, next to the tab — it started as oneuseStatein the page and had to move, because the thing that must clear it is the tab bar (App.tsx::onTab, pure and tested: selecting a tab, including the one already active, returns to the index).Worktreestakes it as an OPTIONAL controlled pair, so a caller that passes neither still gets the page it had. A column with nothing in it is not drawn at all: the grid is built from the columns that HAVE rows, so a chapter with nothing merged yet is ONE full-width panel rather than a tall column beside a stub, and both columns empty is one sentence centred in the page with no shell, no heading and no count. That reverses the first answer — an equal-height shell with a dotted empty field and its own sentence inside — which on a landed chapter was still half a screen spent on an absence; half a screen of styled nothing is not more honest than no panel, only bigger.align-items: stretchstays, because with two columns it is what keeps them level. The three note cards are drawn in every state, both-empty included: they state the rule the board enforces, which is true whether or not a tree exists. The compare BASE belongs to the row's chapter, never to the one in focus: it is resolved inrows()out of the sameboard.milestoneslist the title comes from and rides onWorktreeRow.milestone.branch. It wasboard.milestone?.branchthreaded down as one prop, and with the header on "All milestones" that is""(verbs/_facts.py::in_scopereturnsNonefor zero or several rather than guessing), so every tree on the screen asked the door forcompare("", tk-x)and got the cascade's last step — "this host could not read that diff". A card belongs to a chapter regardless of who is looking at it. There are only two steps and no fallback: the row's own chapter branch, else NO base (the forge's own default branch, the local door asked nothing) — substituting another chapter's branch would draw one chapter's work as another's diff. The diff page itself reads side by side (§16) and carries the card's own thread — there is no worktree comment and there must never be one, because a worktree has no identity apart from its card and a second thread would be two places to say one thing.WorktreeDiffPropsandThreadPropsboth live incomponents/monitor/panels.ts:ThreadPropsspent one wave declared insideWorktreeDiff.tsxbecause the seam was held by another card, and came back at the chapter close. The seam is where a props contract lives; a page-local copy is a wave-length exception with a date on it, never the resting place. -
Reports — a chapter's NARRATION, listed and then read full width (
ui/src/pages/Reports.tsx). The fifth tab, and the first that is not Nova's: a report was a chat artefact when Nova was drawn. The INDEX costs nothing —verbs/pulse.py::runfolds thereportevents intoboard.reportson every read, scoped to the chapter the header picker is on, newest first, withreports_totalbeside it — so this page adds no fetcher and no filter; it is a slice of the one board answer (useBoard.ts). The CONTENT is not on the board and never will be (events.jsonlstores references, never bytes): a row opens a full-width surface that REPLACES the index, exactly as a worktree opens its diff page and for the same reason, and that surface asks the/gitfile door for one report at a time out of the reader's own clone (links.tsx::fileRoute+useGitFile,useGitDiff's sibling — same availability flag, same "a refusal is quoted, never paraphrased" rule). The selection lives inApp.tsxbesidetreeand is cleared by the sameonTab. The sandbox is the whole ofcomponents/reports/ReportFrame.tsx, and it is a security boundary, not a preference: a report is untrusted HTML somebody's agent wrote, and this origin holds the token. An html report goes into<iframe sandbox="allow-scripts" srcdoc=…>— SCRIPTS RUN (a panorama report is a self-contained page with its own inline behaviour, and rendering it dead would ship a broken document that looks fine) andallow-same-originis never beside them, because that pair is not two permissions but the absence of the sandbox: with both, the frame readsparent.localStorage, writesparent.documentand can strip its ownsandboxattribute.SANDBOXis a constant and not a prop, so no caller can ask for a laxer frame. Everything else (forms, popups, top navigation, modals, downloads) stays withheld, and the frame carriesreferrerpolicy="no-referrer"and an emptyallow. A.mdreport is answeredcontent_type: text/markdownand drawn by the shared markdown renderer (ui/src/markdown.ts→components/shared/Markdown.tsx) with no frame and no sandbox — the renderer builds React elements and emits no HTML. Only unrecognised types fall to the<pre>: atext/plainreport is NOT framed —srcdocparses as HTML, so it goes into a<pre>as a React text node — escaped, whitespace intact, and nodangerouslySetInnerHTMLexists anywhere in this dashboard. It is pinned headlessly inui/smoke/sections/report-sandbox.tsxagainst a report the fixture commits WITH a hostile<script>in it (tests/test_ui.py::A_HOSTILE_REPORT): the sandbox attribute, the absence of the forbidden pair, and — the claim that matters — that the dashboard's own document contains no<scriptof the report's at all. -
the card dossier drawer, opening over Monitor and Board through
App'sopenCard— it renders the acceptance criteria no v1 screen ever drew, and carries the UI's ONE write, the comment box with its mention picker . The header's milestone picker scopes every tab at once, and "all chapters" is an ARGUMENT —milestone=*(core/types.py::EVERYTHING), not the absence of one. It sent nothing for a wave, which the server reads as "resolve the scope yourself" and resolves to the single open chapter: the page arrived narrowed to that chapter with the ✓ sitting on "all chapters" the whole time, and clicking the option changed no argument (2026-08-18). Absence still means exactly that — it is the dashboard's OPENING state, andApp::scopeOfresolves it against the chapter the answer came back scoped to, so the ✓ marks the scope the page is actually drawn at.verbs/activity.pyrefuses*in the words of its own doors: a story is one chapter's cards by definition.ui/smoke/sections/all-chapters-scope.tsx. -
the comment toasts, a stack bottom-right over every tab (
ui/src/components/toasts/), because the UI's one write was invisible to everyone but its author: a comment landed and no other session's screen said so. Everything is DERIVED client-side from the feed the Event pane already reads — nothing is stored, no verb changed and there is no Python diff behind it.model.tsis the whole of the decision-making and it is a plain module with no React, no timers and no clock of its own: which comments are new, what the preview trims to, how deep the stack goes and when a toast leaves are all functions taking their clock and cursor as ARGUMENTS, which is what letsui/smoke/sections/comment-toasts-model.tsxand…-stack.tsxpin them underreact-dom/serverwith no jsdom and no stopwatch. "New" is a HEAD DELTA plus an id set, not a per-event seq —verbs/events.pydrops the rowid on the way out, so the client derives the arrival count fromhead - lastHeadagainst a newest-first page one, and the id set catches the one case that arithmetic over-counts (a burst larger thanEVENT_PAGE). A toast is shown once, by event id, ever, and the first load is SILENT: a page of history toasted on mount would be fifty notifications about yesterday. "Already toasted" is a set in memory and never a read-receipt — there is no mark-as-read verb on this board and there must not be one (§11). A toast carries the author's avatar, the card's title and the trimmed text; clicking it expands in place to the whole message, and a SEPARATE affordance opens the card dossier through the sameopenCard. The commented card's tile answers with a short-lived pulse that returns to rest — the BORDER untouched,components/board/CardTile.tsxargues why. No literal colour anywhere in it (theme/tokens.cssonly), andprefers-reduced-motionsuppresses every animation through the query helpercomponents/board/flip.tsalready exported.
Removed on purpose and not to be rebuilt: an "Attention" screen, which is in no Nova section, and an "Hours" tab, which in Nova is the Throughput panel inside Monitor.
The board points at the code (ui/src/links.tsx): given a slug, a sha is an
anchor to …/commit/<sha>, and a chapter compares against the forge's own
default branch. The card dossier's Worktree block opens the dashboard's own
worktree diff view (reading the local clone, no forge slug needed); forge
…/compare/<base>...<head> links remain on the Worktrees index rows, the
WorktreeDiff header and the chapter compare (the compareUrl consumers) — the trunk's name
is not on the board, and a main in one of these URLs would be the UI guessing.
The host is a VALUE (BY_HOST), not a second code path: GitLab differs by
/-/commit/ and nothing else. And it shows the diff ITSELF when the host it is
served from sits in a clone: Files changed in the dossier, a fold under every
commit row, both drawn by ui/src/components/card/Patch.tsx from the step
links.tsx::cascade hands it. See §16 and its amendment for the switch and the
four steps.
Prose is rendered as prose, by ONE renderer. ui/src/markdown.ts parses
(pure, no React, produces DATA) and components/shared/Markdown.tsx turns the
blocks into elements — never dangerouslySetInnerHTML, so React's escaping IS
the sanitiser. Every screen that draws a human-written string calls it: a card
spec, a comment, a released note, a reviewer's verdict, a chapter GOAL, a
chapter rule, a chapter criterion, a card criterion, a mention row and a board
tile's note. It has two modes and no sibling: blocks, and inline — spans
only, for the strings that live inside a layout that already owns their shape
(a numbered tile, a grid cell, a 278px column), because block-rendering a rule
that opens 1. draws a second numbering inside the first. Titles, ids,
branches and the UI's own sentences are NOT prose and are drawn as text. A
chapter goal is the longest writing the board carries — axion's is 4,252
characters — so the pane gives it a max height and its own scroll: never a
clamp and never an ellipsis, since a cut goal is a lie about what the chapter
says. ui/smoke/sections/monitor-panes.tsx pins all of it from the server's own payload (the
the goal renders as markdown, not as characters group).
Colour lives in exactly one file, ui/src/theme/tokens.css; the theme is an
attribute on <html data-tk>, remembered in localStorage, defaulting to the
OS scheme. tests/test_ui.py runs the page headlessly through the smoke
harness (ui/smoke/run.mjs); npm run check runs all four of its steps and all
four are green. Its git diff --exit-code clause reports a bundle that has
drifted from ui/ — red while a .tsx-only wave is in flight, green again once
the chapter-close rebuild lands.
GitHub is the viewer; taskops is the board. Cards read like PRs (base
ms/<slug>, head tk-<id>) and milestones like PR groups — but taskops never
hosts, serves or replicates git content. Patches, trees and blame live in git;
events.jsonl stores REFERENCES AND MEASURES (sha, subject, numstat) and is
replayed forever, so it has no delete and no place for a diff.
Three mechanisms, all client-side, all in the layers that already had git:
| # | mechanism | where |
|---|---|---|
| 1 | a commit event carries numstat — +/- per file, null for a file git could not count (a binary, never a 0) |
gitwork/bind.py → verbs/record.py; drawn by ui/src/links.tsx on the dossier's commit list and on the Event stream |
| 2 | branches reach origin by best-effort pushes at the three lifecycle moments that already exist — done, integrate, land |
gitwork/remote.py::push, called from mcp/gitmoves.py (done) and gitwork/landing.py (integrate, land). landing.py's comment is the contract: best effort; local still landed. Never a gate, never in a commit hook, never a board fact |
| 3 | the repo's forge slug is recorded ONCE, by the side that HAS the repo (init/join), as {host, slug, url}, and rides on the board payload |
gitwork/remote.py::remember → verbs/project.py; consumed by ui/src/links.tsx |
The switch for all of it is git remote get-url origin, never a
local-vs-remote mode. With no origin: nothing pushes, no link renders, and
nothing degrades — a board without a remote behaves byte-for-byte like the
taskops that predates this chapter, with no dead anchors and no column reserved
for one. That case is pinned headlessly (ui/smoke/sections/forge-links.tsx, the no slug assertions: not one anchor
is rendered, and the Worktrees index still draws both its columns and every row
in them) — and it is pinned there precisely because it stopped being this repo's
own case. Until 2026-08-09 this checkout had no origin and the degraded path
was exercised on every run by accident; it now pushes to
github.com/bernatch22/taskops, so the harness is the ONLY thing standing
between that path and a silent rot. Do not weaken those assertions.
The rules this chapter was held to are the milestone's own: LOCAL == REMOTE BY
CONSTRUCTION (a git fact enters the board only as an EVENT written by the side
that has the repo — no verb touches git), and every new payload key optional in
ui/src/types.ts with a fallback in every consumer.
The paragraph above used to end "no verb touches git, no server reads a repo". That second clause was written deliberately, and it is now narrowed on purpose — recorded here rather than quietly dropped:
The dashboard renders the board, PLUS git content served by a host that sits inside a repo. A host that does not, says so.
The reasoning is a fact about who runs the server, not a convenience. Every
dev has the clone by construction: board.json lives inside .taskops/,
inside the repo, and you cannot init or join without a checkout. So "the
side that HAS the repo" — the side the rule above already trusts to write git
facts — is the same machine that serves the page at render time. Asking it for
a diff is not a new trust boundary; it is the boundary that already existed,
read from instead of only written through.
The asymmetry is therefore honest and structural, decided once at
construction and never sniffed per request (Mounts(repo=…),
http/gitdoor.py):
| host | root | /git | /ui/ |
|---|---|---|---|
taskops ui |
<repo>/.taskops — it IS in a checkout |
mounted: git/commit/<ref>, git/compare/<a>...<b>, git/file/<rev>?path= |
the bundle |
taskops serve |
a directory of boards, no checkout | 404 with the reason spelled out, and nothing faked | 410 and one sentence |
The /ui/ column arrived later, and it is the same repo deciding both — see
"API ONLY is now literal" below. Both cells of the serve row are narrowed a
second time by "The hosted window" below: a board that DECLARED a forge may be
answered from a bare read-only mirror of it, and only then. And the page's ADDRESS moved
one notch after that: on a serve-mode host the last column is the board's own
/<board>/, with /<board>/ui/ kept as an alias — "The board's own address IS
the page" at the end of this section.
A viewer meets that gap whenever this clone cannot answer — a ref not fetched yet, a shallow checkout, a commit from before this repo had the branch — and there the UI's declared cascade takes over: numstat from the event → patch from /git → the forge link if a slug exists → one honest sentence. No dead anchor, no empty pane pretending. (The gap used to have a second, worse shape: a browser with no clone at all, pointed at the hosted page. That page was withdrawn — see below — and comes back only standing on a real history: the forge mirror, in "The hosted window".)
That cascade is ONE function — ui/src/links.tsx::cascade, beside the slug and
the link templates it already owned — and ui/src/components/card/Patch.tsx
only DRAWS the step it is handed, so no component holds a fallback order of its
own. It feeds the dossier's Files changed (the card as a PR, over the range
the BOARD recorded rather than two branch names — links.tsx::cardRange: one
recorded commit goes through commit/<sha>, which the door diffs against its
first parent, and several become compare/<first>^...<last>, whose base is the
oldest commit's PARENT so that commit's own changes are inside the range. Both
ends are durable, which is what makes the pane render for a card whose tk-*
and ms/* branches were pruned before this host became the remote — the head
alone was not enough, 0.5.2 shipped that and the pane stayed empty. Then the
file list from stat, each file's patch on expand via ?path=) and the fold on
every commit row. Availability is
DISCOVERED, not configured: the first refusal whose words are
gitdoor.py::NO_REPO flips a module-level flag for the session and nothing asks
again — an unknown ref does not flip it, because that means "ask again for
another ref". All four steps are drawn from the door's own payload in
ui/smoke/sections/git-diff.tsx (the cascade groups, from fixture.git); what no
headless harness reaches is useGitDiff's
effect firing, and that half is covered against a real server in
tests/test_topology.py.
The same cascade now feeds a SECOND surface (the worktrees-as-pull-requests
chapter): pages/WorktreeDiff.tsx, the full-width page a Worktrees row opens,
hands its whole range <milestone branch>...tk-<id> to the very same
FilesChanged — plus a summary bar, which is a prop on it and not a second ask.
Two live BRANCHES there, deliberately, where the dossier asks by sha: that page's
subject is a working tree that exists on disk right now, so where its two tips
stand IS the question. FilesChanged therefore takes a whole GitTarget rather
than a base/head pair — a pair could only ever express the second question.
Nothing else moved to do that: no verb, no stored key, no change to the door,
and no component fetches a patch outside cascade(). The one seam this cost is
FileList exported beside FilesChanged — the pure half, given a step — for
exactly the reason Dossier is exported beside Drawer: the asking half is a
useEffect, and react-dom/server fires none, so the drawn list would otherwise
have no headless test at all.
And that surface reads like a page, not like a pane in a drawer. Three
things, none of which stores anything or adds a verb. (1) The pane's
measurements are a PROP with two named values — PatchSize = "drawer" | "page",
defaulted to drawer, so the dossier's pane is byte-for-byte what it was
(11.5px, 360px cap) while the page gets a page's typography and a cap on the
viewport. (2) Side by side: components/card/split.ts folds a unified patch
into Hunk[] of aligned rows with a line number per side, reading exactly the
prefixes Patch.tsx::tone reads and pairing a run of - with the run of +
after it POSITIONALLY. Anything it cannot parse returns [] and the component
draws the unified view it already had — an empty two-column table reads as "no
changes" and means "I did not understand", so that fallback is the whole safety
of the feature. The toggle is on the page, defaults to split, and remembers
nothing. (3) The card's OWN thread is on the page: the same Thread and the
same CommentBox, fed the dossier App already opened when it opened the tree,
writing through the same update comment=. There is no worktree comment and
there must never be one — a worktree has no identity apart from its card
(gitwork/trees.py pins tk-<id> as branch, directory and id at once), and a
second thread would be two places to say one thing. ui/smoke/sections/diff-page.tsx pins
all of it (the page's pane is a page's, the card's own thread is on the page),
split() against the door's own patch.
What did NOT change: events.jsonl still stores references and measures and
never content; the door DERIVES on demand and nothing it returns is written
back; git still lives only in gitwork/ (gitwork/diff.py + gitwork/patch.py), read-only, behind
the same token door as /rpc and the same envelope as rpc.py. A ref from a
browser is refused by shape, then resolved by git rev-parse --verify --quiet
as one argv element, and from there only the 40-hex sha is used — nothing is
ever interpolated. A commit is spelled git diff <sha>^1 <sha> (first parent
only, so a merge does not explode into its whole branch; a root commit against
git's empty tree), a compare is merge-base(a, b) → b, and an over-cap patch
comes back truncated: true — flagged, never silently cut.
The third question: git/file/<rev>?path=<file> — one committed file's bytes
(the reports chapter). A report is a committed FILE and the event carries only
{path, title, milestone, sha}, so the bytes have to come from the reader's own
clone, exactly as a patch does: diff.resolve for the rev, then git show <sha>:<path> through gitwork/patch.py::show, capped on the same CAP and
flagged the same way (capped() is one function so the two answers cannot
disagree about what truncated means). It answers the same JSON envelope, so
content_type — one of three values decided by extension: text/html for a
literal .html, text/markdown for .md, text/plain for everything else
(gitdoor.py::_kind) — is a FIELD and never
this response's header — the token lives in this origin, so no file can make it
serve HTML, and deciding what to do with text/html is the READER's job. It is
done in one place, ui/src/components/reports/ReportFrame.tsx: an <iframe sandbox="allow-scripts"> with srcdoc, never beside allow-same-origin (§15).
It is not a file server, and the refusal is the feature. The path is checked
by core/reports.py::under() — the SAME call the verb that registers a report
makes, so the two ends cannot drift — which refuses .., an absolute path, a
doubled separator and the directory itself, and never repairs one: a traversal
normalised into something acceptable is the bug. Anything outside
.taskops/reports/ is a 400 that names the directory, and tests/test_topology.py
walks every shape (a real secret one directory up included, asserting its
contents appear nowhere in the answer). taskops serve mounts no /git
from a checkout it does not have: the switch is Mounts.repo, decided once at
construction, and a new question underneath it opens no door. The hosted-window
amendment below changes what repo may point AT — a bare mirror of the
declared forge — never the switch itself.
The amendment above says a host that sits in a repo may read it. The command
then contradicted it for the case that matters most: cli/serving.py::ui had
two branches, and the remote one was _open("<server>/ui/?token=…") — a
redirect to the machine that owns the BOARD, which by the same rule has no
clone. On a shared board, every diff a reader opened fell straight through the
cascade to a forge link or a sentence. The window was pointed at the wrong
machine.
taskops ui now always serves the window, from the checkout it stands in.
The routes do not change, so the committed bundle is untouched by any of this:
dev A (laptop) dev B (laptop)
┌────────────────────────────┐ ┌────────────────────────────┐
│ $ taskops ui │ │ $ taskops ui │
│ / ← the bundle │ │ / ← the bundle │
│ /board/rpc ─────┐ │ │ /board/rpc ─────┐ │
│ /board/git ─ A's clone │ │ /board/git ─ B's clone │
└──────────────────┼─────────┘ └──────────────────┼─────────┘
└──────────────┐ ┌─────────────┘
▼ ▼
┌──────────────────────────┐
│ the server: API ONLY │
│ /<board>/rpc events │
│ /<board>/feed signal │
│ /healthz │
│ /<board>/ui/ 410 — │
│ "run taskops ui" │
└──────────────────────────┘
"API ONLY" is now literal (decided 2026-08-08, the other half of the same
decision). The server used to serve the bundle too, from a --ui flag with a
packaged default — so the box above answered /ui/ with a real dashboard, and
that dashboard could not draw a single patch, because the machine it was served
from deliberately had no clone. It was a degraded window presented as the
window; the redirect above merely pointed people at it. The binary serves the
window; the server serves the truth.
So: Mounts.ui is not a parameter, it is repo's shadow — one construction-time
switch mounts /git AND the bundle, and only a process standing in a checkout
has either. /ui/ on a board host answers static.py::NO_UI as plain text with
410 Gone, not 404: 404 says "no idea, maybe later", and this page was
withdrawn on purpose and is never coming back to a host with no clone — and a
host holding the board's forge MIRROR is no longer that host ("The hosted
window", below). Plain
text and not the JSON envelope because the only reader who reaches that door is
a human with a browser. The --ui flag is REMOVED, not deprecated: there is no
command line that puts the decision back. The bundle still ships inside the
wheel — pip install taskops + taskops ui still needs no node toolchain; only
the server-side mount went. tests/test_topology.py pins all of it: the
sentence and its 410 on a serve host, the bundle still served by a host that
sits in a repo, and serve --help carrying no --ui.
The whole difference is one if, in http/rpc.py::answered: a config with a
url relays the /rpc body to <url>/rpc with the bearer from remote.json
(http/upstream.py); without one, verbs.call on the local stores, exactly as
before. Mounts carries upstream the way it already carries repo — decided
by the caller at construction, never sniffed per request — and /git is mounted
from the local clone in BOTH modes, which is the point.
Why forward rather than point the page at the remote. The alternative was
absolute URLs in the client, CORS on the server, and the team credential handed
to the browser to sign those calls: three surfaces touched for one result, and
the third is decisive. Forwarding keeps the page on one base URL with relative
paths — no CORS, no client change — and the team credential never leaves the
local process; the browser holds only the token taskops ui mints into
ui.json, which is local and revocable. tests/test_topology.py searches every
served byte, and their headers, for the team token.
A SESSION RUNS OUT AND THE WINDOW DOES NOT (2026-08-18). A host session is
twelve hours (http/login.py::SESSION_TTL), and a window is a thing you leave
open: _upstream read the token lying in remote.json and Upstream held it
for the life of the process, so the morning after, every read answered "that
credential expired" — and the habit that taught was taskops join, a human
re-pasting a credential the machine can mint itself, which is the exact thing
the session chapter exists to end. Two halves, both fixed: the command opens
with session.fresh, like every other command has since that chapter landed,
and the window is handed session.refresher so it can sign in AGAIN mid-flight
— board.py::RemoteBoard.call's retry, narrow for the same reason (the server
said EXPIRED, this process holds a key). A window with no way back in — a
standing bearer, a public viewer — is unchanged: refresh is None and nobody
replaces a token behind its owner's back (session.py). Upstream still knows
nothing about keys or files: it is handed a callable.
A refusal from the remote arrives in the SERVER's own words, status and all:
upstream.py returns (status, bytes) and re-wraps only a body that is not an
envelope. A board that says "held by agent:berna/w1" with a 409 must not reach
the reader as a local 500, or they debug the wrong machine.
The live signal is a POLL, never a relay. No WebSocket is proxied through
the stdlib handler and no SSE stream is bridged: the local host asks the remote
for its seq — one board call — every REMOTE_WATCH_SECONDS (3s, against 1s
for a local rowid lookup) while somebody is connected, and pokes the socket it
already serves when the number moves. http/feed.py's contract is what makes
that safe: a message is a SIGNAL, not a payload, so a late or missed poke costs
one tick and can never show something the board did not say.
A ref the viewer has not fetched is not an error (gitdoor.py::STALE). On a
shared board most branches belong to other people's cards; a card's branch
reaches origin when it closes, and until this clone fetches it, "not here" is
the truth about this disk. So the door says so and names the command —
tk-91a27e is not in your clone yet — git fetch origin tk-91a27e brings it —
and links.tsx::cascade QUOTES a refusal the door actually sent rather than
paraphrasing it, because only the door knows which ref was missing. Nothing is
fetched on the reader's behalf: a background fetch inside a read-only door would
move a branch under a worktree somebody is sitting in.
A repo joined to nothing behaves exactly as it did.
Reversed one day later — "The host becomes the remote", below. The direction
of the git flow inverts: the host holds the board's OWN repo (repo.git),
takes pushes from enrolled principals, and mirrors OUTWARD to the forge;
mirror.git retires. This section stays as the argument it was, because what
it got right (a hosted window must stand on a real history; the switch is
decided at mount time; the credential is a deploy key, never a token)
survives the reversal, and what it got wrong is named there.
The first amendment narrowed "no server reads a repo" to "a host that sits in a repo may read it". This one narrows the other clause — the server deliberately has no clone — and, as before, the change is recorded here rather than quietly made:
A board host MAY hold a bare, READ-ONLY mirror of the board's DECLARED forge — and only then does it answer
/gitand/ui/. A board with no declared forge is exactly what it was: 404 on/git, 410 on/ui/, each refusal namingtaskops board forge <owner>/<repo>as the door.
Why this is not the degraded window "API ONLY" withdrew. That window's flaw was
never that a server drew a dashboard — it was that the machine drawing it had
NO git at all, so every diff fell through the whole cascade to a forge link or
a sentence, a blank presented as the window. The mirror removes the flaw, not
the caution: it is the same history the forge already holds — git clone --mirror of the repo the owner declared with board forge, refreshed by ONE
bounded on-demand fetch when a requested ref is missing, then answered stale
rather than blocking — so a diff served from it is the diff GitHub would show,
read by the same gitwork/diff.py doors, behind the same visibility rules as
/rpc. And the dev-side story does not move an inch: taskops ui still serves
the window from the checkout it stands in, reading the dev's OWN clone,
including refs the forge has never seen. The hosted window is for the reader
who has no clone — the case the local window, by construction, cannot serve.
Where it lives: <root>/<board>/mirror.git, a bare repo beside the board's
events.jsonl — later cards follow this wording. It is DERIVED and disposable
in exactly the sense cache.sqlite is: delete it and it re-clones from the
declared forge; nothing in it is truth the forge does not hold. Its ONLY source
is that forge. It takes no client pushes — the host still exposes no receive
door of any kind — and it is not a second trunk: nothing lands there, nothing
is read back into the log from it, and taskops_merge still happens in the
dev's shared checkout.
The credential story is §19's, unchanged in kind. A public repo needs no
credential at all. A private repo is fetched with an ssh deploy key on the
host's filesystem — read-only, minted and installed by the OWNER, revocable
on the forge like any deploy key — which is the ONE new credential this chapter
adds, and it is the owner's business exactly as board forge itself is. What
remains banned is what §11 and §19 already ban: a stored GitHub token (the
mirror's remote is an address, never a token), a write credential in any form,
GitHub as a second identity system, and any dev credential travelling to the
host — nobody's gh auth token is involved, because the owner already declared
the repo and the mirror pulls from the outside like any anonymous (or
deploy-keyed) clone.
Still banned, spelled out: client pushes to the host and git replication
between clones (the mirror replicates FROM the forge, one direction, and no
clone ever pushes to it); a write credential on the host; a second trunk; a
--ui flag or any per-request sniffing (whether the mirror exists is decided
at mount time, the same Mounts.repo switch, now pointable at mirror.git);
and anonymous writes — a public board's hosted window is a READ, and it leaves
no presence row.
So static.py::NO_UI and gitdoor.py::NO_REPO change meaning, not words of
intent: each stops saying "this host never has a repo" and starts saying "this
BOARD declared no forge, so this host holds no mirror to read from" — a state
the owner ends with one taskops board forge. The refusal still names the door.
gitwork/remote.py was born TWICE in one wave: two workers, each over the
200-line budget in a different module, each split the origin concern out under
the obvious name, neither able to see the other. Both specs said "check git remote get-url origin". Unlike the earlier fan-out's silent ago() twins, the
same-path collision made the merge refuse loudly and the halves were
complementary, so the union IS the module — the full post-mortem is its
docstring. The planning lesson generalises the one that fan-out already
concluded: taskops still never parses source, and collisions() is still not
widened; what changes is what the human looks for before dispatching. A NOUN
that appears in two specs is a seam, and a seam lands serialized first.
And since 2026-08-09 the board computes it (tk-1eecbf). "What the human
looks for before dispatching" was a sentence in mcp/server.py::INSTRUCTIONS,
and twice in one week it was not remembered, because nothing derived it.
core/seams.py is that derivation, PURE and level 1: terms(spec) reads the
planner's PROSE — backticked spans longer than one bare word, and identifiers
joined by ::, ., / or _ — and wave(cards, ready) partitions the READY
column, greedily, in its own priority order, into what is safe to fan out
together and what is held, each held card naming the card it clashes with and
the exact overlap. FILES before TERMS: a declared file is a fact the planner
wrote, a shared term is an inference from prose.
Three lines hold it to the rules above it. Nothing is stored — the wave is
recomputed on every taskops_board and there is no new event, table or cache.
taskops still never parses source: terms reads specs and collisions()
is still not widened. A warning is never a lock: the wave rides under TAKE
in mcp/boardview.py and in the brief of an assign that names cards it holds
apart (mcp/brief.py::_apart), and the assign proceeds regardless.
The extraction is deliberately NARROW, and that is the whole design: ordinary
prose yields the empty set, a lone `done` is not a concept, and - is
not a joiner so "post-mortem" is prose. A missed seam costs one duplicated
helper; a false hold costs the feature, because an orchestrator that is held
back over a coincidence stops reading the advice. render.py passed its
200-line budget when the wave line was added, so the board panorama moved to
mcp/boardview.py at the seam that file already had — the one big screen on
one side, what every other tool result is made of on the other.
The dashboard draws NOTHING of this yet: BoardPayload.wave is declared in
ui/src/types.ts with its @source line so the pane that eventually draws it
starts from the server's real shape, and null (fewer than two ready cards,
nothing to decide) is deliberately a different state from absent (a board older
than the wave).
The hosted window shipped on 2026-08-30 and was unusable the next morning for
the exact reader it was built for: a PUBLIC board, opened from its own hosted
page, answered "unknown credential — run: taskops join". Nothing about
visibility was wrong. The page simply had no token, so the browser sent
Authorization: Bearer with an empty value; auth.token_in's removeprefix
did not match the header without its space-and-value, and it returned the
literal string Bearer as if that were somebody's token. creds.check then did
its job perfectly on a token nobody ever minted.
So the fault was never in the visibility rule and never in the client. It was in the one place that decides what a header MEANS:
An
Authorizationheader that carries no value is the ABSENCE of a credential, identical to sending no header at all. A header that carries a value is a claim, and a claim that does not check out is refused with the sentence it has always used.
Both halves are load-bearing, and the second is why this is not the tempting
one-line generalisation. "Treat anything unparseable as anonymous" would make a
typo'd token read a private board as far as its visibility allows and, worse,
would turn an expired credential into a silent downgrade instead of the loud
unknown credential it has always been. Empty is not wrong; wrong is wrong —
and the two refusals stay different sentences: a caller who presented NOTHING to
a private board is told how to join, a caller who presented something unknown is
told it is unknown.
It lives in ONE extractor (http/auth.py::token_in) because every door — rpc,
feed, git, the static page — asks that function and nothing else. A second
door with its own opinion about a header is how the fleet ends up with two
credential rules, which is the failure mode §19 exists to prevent. taskops ui's window never saw this: it mints a token before it opens a tab, so the only
client that sends an empty bearer is the hosted page, and the hosted page is
younger than the extractor.
The lesson generalises past auth: anonymous worked only because no client
existed. Every test that pinned "no header reads a public board" sent no
header, because the only readers were curl and the MCP client. A browser
cannot express "no header" through a fetch wrapper that always sets one — so a
contract that is only ever exercised by hand-written clients is not pinned,
it is merely unvisited. tests/test_topology.py now sends the bare header
alongside the absent one, on a public board AND a private one, which is the pair
that says which of the two rules above is being tested.
The same report carried a second complaint, and it is a design one: the page was
at /<board>/ui/. A board host serves exactly one thing to a human, and it had
been given a sub-path — so the URL a person pastes says ui twice over (once in
the product's name for the thing, once in the path) and the shortest address on
the host, /<board>/, answered 404. Whatever a host serves to people belongs at
its own address; a prefix is for the doors people never type.
A board's page is served at
/<board>/(and/<board>, bare). The MACHINE doors move under/<board>/api/…—rpc,git/…,feed,invite/redeem.
Two facts make that router unambiguous rather than a guess per request. The
prefix is stripped ONCE, in http/routes.py::split, shared by GET and POST, so
/<board>/api/rpc IS /<board>/rpc — same handler, same credential, same
words, and no door can drift from the other spelling. And the page's own
relative links (./app.js, ./style.css off index.html) now arrive as board
tails, which the router separates from doors by a CLOSED SET: static.py::asset
asks the packaged bundle what files it actually ships, flat, with a suffix in
TYPES. None is named rpc, git, feed, ui or api, so an asset can
never shadow a door and a door never shadows an asset — and a bundle that grows
a font ships without a router edit. The host's own one-segment doors (/login,
/rpc, /healthz) are untouched, because the strip lives in the TAIL and never
sees them; a board literally named api still answers at /api/….
The 0.5.0 spellings are kept, not deprecated — /<board>/ui/,
/<board>/rpc, /<board>/git/…, /<board>/feed all answer, unprefixed and
without a redirect. Not out of politeness: links to them were pasted the day
they existed, .mcp.json files and agents are configured against them, four
legacy-bearer production boards speak them, and taskops ui's upstream forward
builds them. A redirect would have been the tidier story and would have broken
every POST that follows one badly. So the rule this chapter adds about old
addresses is the one it also obeys: an address that ever worked keeps
answering, and the new one is where the page POINTS people. ui/src/client.ts
derives its base from the page's own location (baseOf) precisely so the page
never hardcodes which of the two it was loaded from.
Both faults are pinned live, not only in the suite. smoke.sh at the repo
root is the re-runnable list — every assertion in it is one of these two reports
or something 0.5.0 already promised, read-only, and it exits on the first
failure:
sh smoke.sh # taskops.bernardocastro.dev, taskops-v2
sh smoke.sh <host> <board> <private-board>This section has been narrowed twice and each time the change was recorded rather than quietly made. This amendment is larger: it REVERSES a ban rather than narrowing one, and the words it reverses are one day old. "The mirror takes no client pushes — the host still exposes no receive door of any kind" stops being true. From this chapter on:
The board's host holds the board's git, as a real remote. A checkout's
originIS the host:git pushlands there,git clone/fetchreads from there, and the declared forge becomes an OUTBOUND mirror the host pushes to, best effort. The host never prunes a card branch.
Be fair to the ban before reversing it. "Client pushes to the host" was
banned when the host had no git at all — a directory of boards, subprocess
nowhere near it — and the failure it guarded against was real: two clones
replicating git state between themselves is split-brain with a merge driver,
and a host that accepted pushes with no identity story would have been an
anonymous write with a packfile attached. Both hazards are still hazards.
What changed is not the hazards; it is that the host now HAS an identity
story every door already checks (§19), and that the topology proposed here is
not replication between clones at all — it is a hub, one direction on each
leg. Writing the ban down was right. What the day-old design got wrong is
narrower and worth naming exactly.
What the pull-mirror got wrong. The mirror's one source was the forge, so the board's view of the code became a FUNCTION OF SOMEBODY ELSE'S BRANCH HYGIENE — three failures, all observed live against the hosted window on 2026-08-31, the day after it shipped:
- a worktree branch that was never pushed does not exist for the host, so a card being worked on had no diff at its URL until its close pushed it;
- a
tk-*branch pruned on the forge when its chapter landed disappeared from the board, even though the work is intact and reachable from master. Verified against production:git ls-remote originhad neithertk-dfaff7norms/parallel-by-mechanism-the-board-, yet both cards' work is in the trunk. The stale sentence called that "normal, not a fault" — true of the MIRROR, and exactly the fault of the DESIGN: a landed card's diff is the board's own record and it had been made erasable by a forge-sidegit push --deletethe board never sees; - and structurally, the host's git was a copy of a copy: worktree → GitHub → mirror, with GitHub — the viewer — sitting in the middle of the board's own data path.
The original ask, restated by Berna the same day, was the other topology all
along: the taskops server keeps the history and mirrors it ONWARD to GitHub.
The cost of adopting it now is small by construction: gitwork/bind.py
already pushes origin <tk-…> on post-commit, best effort, never a gate — so
for a joined checkout the only thing that changes is WHICH remote origin
is. The lifecycle pushes in gitwork/remote.py::push keep their contract and
their ten seconds; they just stop travelling through a third party.
The decisions the rest of the chapter follows, each argued:
Transport: git smart-HTTP over the existing door, not ssh. The relay in
front of this host (§17) exposes no port but HTTPS, so ssh transport would
begin by re-plumbing the deployment. It would also mean a SECOND auth system
— an sshd, an authorized_keys, a key-to-principal mapping — beside the one
the board already has, for keys that are already enrolled on the host and
already end in a bearer session every door checks (§19: one introduction, one
credential). Git speaks smart-HTTP natively (info/refs?service=…, then
git-upload-pack / git-receive-pack POSTs), authenticates it with HTTP
Basic, and a session token rides in that password field with no client-side
tooling at all. ssh becomes worth revisiting only if BOTH of these turn true:
a deployment that fronts raw TCP (no relay), and clients that cannot hold a
session token. Neither exists, and the second is unlikely ever to — taskops join mints the session that signs everything else.
The address: /<board>/repo.git. A git client is pointed at
https://<host>/<board>/repo.git and everything git asks for lives under it:
/<board>/repo.git/info/refs?service=git-upload-pack,
/<board>/repo.git/git-upload-pack, /<board>/repo.git/git-receive-pack.
It cannot collide with anything the router already serves, and each half of
that is a fact about an existing closed set, not a hope: the JSON diff door
is the tail git/… (and api/git/… — http/routes.py::split strips the
prefix once, and repo.git does not start with api/ so the strip never
touches it), while repo.git is its own first segment; and the page's assets
are static.py::asset's closed set — flat filenames whose suffix is in
TYPES, and .git is not in TYPES, so no bundle that ever ships can
shadow the repo and the repo can never shadow an asset. The .git suffix is
also the convention every forge trained every tool on, which is why the name
is not code or src: an address a person guesses should be the one that
works.
On-disk: a NEW bare repo, <root>/<board>/repo.git, and mirror.git
retires. The mirror does not get promoted, for two reasons written into its
own contract. It is DERIVED and disposable — "delete it and it re-clones" —
and the board's real remote is the opposite thing: truth the forge does not
necessarily hold, deletable by nobody. And its refspec is --mirror, whose
fetch semantics are make local match the forge, prunes included — the exact
behaviour this chapter exists to kill would be sitting in the repo's own
config, one habitual git fetch away. A truth-holder configured to erase
itself is not a truth-holder. So: a fresh git init --bare repo.git beside
events.jsonl, and on a host that already has a populated mirror.git
(production does, today), repo.git is SEEDED from it — one local
git clone --bare mirror.git repo.git, on-disk and cheap — so no history the
mirror held is lost, and the mirror-mode config dies with the old directory,
which is then removed. Wired as gitwork/bare.py::adopt and called by
http/repos.py, the first door that needs an answer: it is the ONE read path
in the codebase allowed to write, argued there — a migration of a history the
host already possesses is not a directory conjured by a stranger's question,
and a board holding neither repo still leaves the disk untouched. mirror.py
is DELETED in the same card rather than left as a second source; a fallback
that could only ever answer for refs the FORGE has is wrong in exactly the case
the reader asks about (a branch nobody pushed, a branch the forge pruned), and
http/repos.py carries that argument. http/repos.py points /git and the
hosted window at repo.git from then on: the window's diffs are read from the board's OWN
repository, with no dependency on the forge — which is the acceptance the
chapter is held to.
Roles: a push is a write, and §11's anonymous-write ban covers it. Only
an enrolled principal may push — dev:<name> and agent:<dev>/<name> alike,
because workers are exactly who bind.py pushes as, on every commit. An
anonymous caller and a read-only credential are refused at the receive door
with the sentence that names taskops join. A read — clone, fetch,
ls-remote — follows the board's VISIBILITY exactly as /rpc does: a public
board clones anonymously (and, per §11's invisible-write ban, that read
leaves no presence row), a private board clones with the credential the
doors already check. One rule, already written, now asked by one more door.
The host never prunes, and never takes a force. A card branch pushed to
the host is readable at its URL forever — that permanence is the point of the
reversal, so it is enforced, not hoped: the receive door refuses a ref
deletion and a non-fast-forward push outright. There is no flag that opens
either, for board rm's reason (§11): --force is a word that names no
consequence, and what it would destroy here is the diff of a landed card —
the board's own record. History rewriting, when it is ever needed, is the
owner's deliberate act against the host's filesystem, not a verb.
The outbound mirror: best effort, never a gate, failure visible. The
forge relationship inverts, it does not end: a board that declared one
(taskops board forge <owner>/<repo>) gets its history pushed ONWARD, from
repo.git to the forge, at the same lifecycle moments remote.py::push
already owns — and a forge that is down MUST NOT fail the client's push,
which lands on the host and is done. Best effort never means silent: a mirror
push that fails is a FACT a reader can see — surfaced where the board already
surfaces derived facts, on the payload, never swallowed into a log line —
because an invisible best-effort is how the forge quietly drifts a month
behind. The credential is an ssh deploy key on the host's filesystem with
WRITE on the forge — and that is a real escalation over the mirror's
read-only key, so it is named as one: minted and installed by the OWNER, an
explicit act, revocable on the forge like any deploy key, and scoped to that
one repo. §19.2 carries the credential argument. What it is NOT: a GitHub
token (the key opens one repo, not an account), and never anything a dev
supplies.
Wired as gitwork/onward.py, started by the receive door once git's own exit
code says the push landed (http/gitpack.py) and running on a background
thread — best effort in TIME as well as in outcome, since inline the client
would feel the mirror as a ten-second gate on a push that already succeeded.
Two pushes racing need no lock: the refspec is the whole refs/heads/*, so a
later push subsumes an earlier one; only the REPORT is ordered, by timestamp
(store/mirroring.py). The report is a row in live.sqlite — alive, never an
event, because "could this host reach GitHub" is false the moment the log
travels — and it rides on the board payload as mirror (verbs/pulse.py,
under forge's own contract: nothing to say sends no key), drawn as one
MIRROR line by mcp/boardview.py, success included, so "silence" can never
mean both "up to date" and "the key expired a month ago". The outbound remote
is one named forge inside repo.git, added by the owner by hand — the ssh
address IS the credential story, and a forge declared with no remote reads as
a failure naming that exact command.
A board with NO declared forge gets everything except the outbound
mirror — and that is not a fault. Push, clone, the hosted window's diffs,
the permanence: all of it stands on repo.git alone. The forge was the
SOURCE under the pull-mirror, so a board without one had no hosted window at
all; under this topology the forge is a projection, and a board that wants no
projection is simply complete. NO_FORGE's refusal retires with the mirror
it described — into gitdoor.NO_REPO, which now says the true board-level
fact ("nobody has pushed this board's code here yet") and names the two moves
that end it. The HOSTED WINDOW's gate moves the same way: repos.backed asks
for repo.git, not for a forge, so static.NO_UI stops promising a page in
exchange for taskops board forge — and the missing-ref sentence's AUDIENCE
split (tk-9cde88) outlives the mirror that prompted it, the host's half
rewritten as stale.HOSTED: this host prunes nothing, so a ref absent here was
never pushed here. The window's half is unchanged, byte for byte.
Beside §20, not inside it. board push/board pull move the BOARD — the
event log, the one history nobody can regenerate — and this chapter moves the
CODE. The two lifecycles share a host and nothing else: board push still
promotes a history exactly once onto an empty target, board pull is still a
snapshot, board rm still guards on possession — and board rm now names
one more thing it destroys, the board's repo.git, judged by the same
possession logic: a history this checkout holds (the clone IS possession of
the git half) is deletable, one it does not hold is refused.
What stays banned, spelled out, because a reversal invites the question:
- replication BETWEEN clones. This is a hub — worktree → host, host → forge, one direction on each leg. No clone pushes to another clone, no clone pulls the board's state from anywhere but the host, and the host never pulls git from the forge again.
- a stored GitHub token, and GitHub as a second credential type. §19, unmoved. The outbound key is a deploy key: one repo, revocable, the owner's.
- anonymous writes, in any form — a git push included. The receive door is behind the same credential wall as every write verb.
- a hand-rolled git protocol. The pack protocol is git's, spoken by
git's own plumbing through the ONE subprocess module (
gitwork/run.py), the way SSHSIG verification is OpenSSH's (§11). A Python reimplementation of pkt-line is the crypto own-goal with a different file extension. - a
--forceor a prune that could erase a card branch on the host. Argued above; it is the reversal's own reason turned into a rule.
The reversal gave the host a git door and left the client unable to NAME it:
https://<host>/<board>/repo.git existed only inside http/gitpack.py, so
pointing a checkout at it meant reading the source. taskops remote git is
that sentence — printed to paste, or --add to write it here — and it is a
third action on taskops remote rather than a twelfth top-level command,
because recording where the board's git lives is the same act as recording
where the board lives (cli/gitremote.py).
origin is never written, and --name origin is refused outright. Not
"left alone if it exists": refused. A checkout's origin is somebody's own
setup — very often their GitHub — and no state of it is this command's to
decide, its absence included. The remote is taskops; a name already in use
is a refusal that names --name <other>, never a set-url. Adding a remote
is CONNECTING (the CLI's promise); repointing one is managing.
The credential is a HELPER, never a URL. https://x:<token>@host/… is the
obvious spelling and it is banned here for two reasons, either sufficient:
git remote add persists its URL in .git/config, plaintext, so a live
session token would sit in the one file nobody thinks to look at; and a
taskops session EXPIRES, so a token baked into a config is wrong within the
hour and cannot renew itself. What gets configured is
credential.<host>.helper, pointing at taskops hook credential — git asks
for a password at push time, the helper mints or renews a session from the ssh
key already on disk (identity.establish, the same entry every board verb
uses) and hands it over on a pipe. store and erase are answered with
silence: a helper that caches is a helper that persists a token, and minting is
free. One credential story, §19's, reaching git through git's own door — and
allowed_signers remains the only thing the host keeps.
The address is NOT a board-payload field, and that is the argued half. The
payload carries the forge and the mirror fact, so repo_url looks like it
belongs beside them. It does not: the server cannot know its own public
address — a proxy, a port-forward, the local taskops ui window, and every
reader already reached it at an address the server never saw — so a stored one
is the first field to rot and the first to send a dev to the wrong host. The
address is (the host you asked) + /<board>/repo.git, a derivation the CLIENT
can always do correctly and the server never can. board ls prints that shape
once, and names the command that spells it exactly.
The v1 server this project replaces ran on the home box as a pm2 entry named
taskops, a taskops serve out of /home/berna/taskops-app/.venv on
127.0.0.1:2180, hosting four boards from /home/berna/taskops-server/. It
is gone: process deleted, pm2 save written, install removed. What replaced
it, and the ORDER, which was the whole safety of the operation:
back up all four OFF the box (rsync + md5 both ends)
→ migrate all four locally with the v1 migration script (deleted once the
crossing was done — the four boards it converted are the record)
→ reconcile every count against each board's own v1 db.sqlite
→ stand v2 up on :2181 BESIDE the running v1, prove it by counts
→ move nginx's proxy_pass 2180 → 2181, reload
→ verify over HTTPS at the real domain, by counts
→ only then delete the v1 process and its install
Nothing was deleted before the replacement was verified serving, and the four v1 board directories are still on disk untouched — they are the last backup.
A board host is not a code tree, so it does not ship with shipway.
shipway rsyncs a project directory and restarts it; a taskops host is one
wheel installed into a venv plus a directory of board logs that must NOT be
rsynced over. The deploy is therefore: build the wheel (uv build --wheel),
pip install it into /home/berna/taskops-v2-app/.venv, and one pm2 entry
pointing --root at /home/berna/taskops-v2-server/. Upgrading is
pip install --force-reinstall <newer wheel> + pm2 restart; the boards are
untouched by it, which is the point of keeping them out of the install.
That upgrade was exercised the same day (tk-c86312): the host was still serving
the wheel built at stand-up, four chapters behind, so axion's 4252-character
goal printed its asterisks and backticks literally. The installed wheel was
copied to ~/taskops-v2-app/rollback/ before anything was replaced, the new
one was checked for the prose markers before it was shipped, and the proof was
the bytes production hands out: the served /axion/ui/app.js is md5-identical
to the committed src/taskops/ui/app.js, and the four events.jsonl are
md5-identical before and after. README's "Upgrading a host" is that procedure,
written down. One doc claim was wrong and is fixed there: /healthz's boards
counts boards OPENED so far, not boards on disk — a board mounts on first
request (http/mounts.py::stores), so a just-restarted process says 1, not
4, until each board has been addressed.
That md5-of-the-served-bundle check is history, not the procedure any more.
Since §16's "API ONLY" amendment the host serves no bundle: /<board>/ui/
answers 410 and one sentence (until the hosted-window chapter deploys a board
whose forge is declared — §16, "The hosted window"), so there is nothing at the
domain to md5. The
wheel is still checked for its markers before it ships — it is the same wheel
teammates install to get taskops ui — and the domain is verified by DATA,
through /rpc.
Production runs this tree since 2026-08-09 (tk-df8e64). The upgrade was the
same three steps and its second run of README's "Upgrading a host": the wheel
installed at tk-c86312 was copied to ~/taskops-v2-app/rollback/ and PROVEN to
be the pre-upgrade one — every taskops/*.py in it md5-identical to what was in
site-packages, and it still carried taskops/http/static.py and
taskops/ui/app.js, the mount this upgrade withdraws — then the new wheel was
checked for chapter-goal and markdown-inline in taskops/ui/app.js before it
left the laptop, pip install --force-reinstall, pm2 restart taskops-v2. All
four /<board>/ui/ now answer 410 with http/static.py::NO_UI verbatim,
/healthz says 4 once each board has been addressed, axion's chapter still
carries its 4252-character goal through the public /rpc, and the four
events.jsonl are md5-identical before and after. End to end: taskops ui from
~/axion-v3 served the window on 127.0.0.1, its /board/rpc answered from the
remote (seq 847) and its /board/git/compare/… returned numstat and patch
read from that clone — the case §16 designed the local window for.
Two things README said that reality corrected, and both are fixed there: the
upgrade's scp target /tmp/taskops-*.whl collides with wheels left by earlier
runs, so pip is handed a glob it refuses (Invalid wheel filename) — ship into
a directory named for the card instead; and touching /<board>/ui/ does NOT
mount a board any more, so it no longer moves /healthz's count. Only /rpc
does.
The four boards migrated as: axion 926 v1 events → 845 + 81 named drops (86
cards, one chapter ms-fe528b, its 4252-character goal and 9 rules intact),
agenda 43 → 35 + 8, notas 59 → 48 + 11, probe 31 → 17 + 14. Every card count,
status split and dependency edge was reconciled against that board's own v1
db.sqlite before anything was switched, and again over HTTPS afterwards —
never against an exit code. agenda, notas and probe carry no milestone because
their v1 logs never created one; their context entries are named drops, not
silent ones.
The host has an OWNER since 2026-08-09 (tk-c37061) — the FOURTH run of README's Upgrading a host, and the only one whose go the human wrote out loud, because it is the run that gives the box an identity. Same three steps, same order:
- the rollback first, and PROVEN: the wheel installed by tk-df8e64 was
copied to
~/taskops-v2-app/rollback/taskops-2.0.0a0-20260809-preupgrade-c37061.whland checked module by module — all 80taskops/*.pyin it md5-identical to site-packages, still carryingtaskops/http/static.py, and NOT carryingtaskops/http/login.py, which is what makes it the pre-chapter wheel rather than a leftover in/tmp; - the artefact checked before it left the laptop: the wheel carries
taskops/http/login.py,admin.pyandingest.py, and itstaskops/ui/app.jsstill carrieschapter-goalandmarkdown-inline; pip install --force-reinstallout of/tmp/tk-c37061/,pm2 restart taskops-v2.
Verified by CONTENT at the real domain: axion's /rpc answers seq 847 with
its 4252-character goal to its legacy bearer token (the fleet rule, proven
against the fleet and not a fresh keyed board), the other three refuse that
same token with the board-scoped message they refused it with before,
/<board>/ui/ still answers 410, and the four events.jsonl are
md5-identical before and after.
Then the one command that is meant to run over ssh, and the last ssh this
host needs: taskops server init --root /home/berna/taskops-v2-server --key <berna.pub> --owner berna, the public half of this laptop's ~/.ssh/id_ed25519
shipped over. /login had been answering the pre-init refusal that names that
exact command; with no restart at all it began answering a nonce. Everything
since has gone over the API from the laptop with the key DISCOVERED, no --key
and no URL: taskops remote add https://taskops.bernardocastro.dev · board create (→ taskops-v2) · board ls (which is also the four legacy boards'
content check: agenda 35, axion 847, notas 48, probe 17) · board visibility public.
taskops-v2 is the first PUBLIC board, and the stranger was simulated
rather than argued: a fresh git clone of this repo into a scratch directory
with a fake HOME — no invite, no token, no ssh key to discover — ran taskops join https://taskops.bernardocastro.dev/taskops-v2 and got a read-only join
(board.json with "readonly": true, a remote.json holding an empty token,
nothing minted, no key registered). Anonymously over HTTP the board answers
visibility: "public"; an anonymous plan is refused with the invite-and-key
sentence; a private board (axion) still refuses anonymous read entirely. And
the rule held where it is invisible: after the whole anonymous crawl the board
is still at seq 2 and team still lists only dev:berna, so not one read
by anon renewed a lease or wrote a presence row.
What is NOT done here is this repo's own promotion. taskops board push
was run bare and refused, correctly and by construction:
somebody is holding a lease on this board right now (agent:berna/k9 on
tk-c37061) — a push moves a history, and a card being worked on is a fact
that has not finished happening.
The card that ships the promotion is the last card of the chapter, so its own
lease is the thing in the way — the push therefore belongs AFTER that card
closes, run by the orchestrator, and step 5 of cli/push.py (board.json +
remote.json, .taskops/board/ archived) is what commits the URL a cloner
then gets for free. Nothing was forced and no config was flipped: board.json
was still {} after the refusal.
Emptiness became "no history but its own configuration" — the FIFTH run of
README's Upgrading a host, 2026-08-09 (tk-bffa26). Promoting this repo's own
board is what found it: board create then board visibility public and only
LATER board push left the target holding a project event the push never
observed, and the two-histories wall refused a push that had nothing to merge.
ingest.py::_birth is now _configuration: project events whose op is in the
CLOSED list {created, visibility, remote, forge} are exempt while the board holds
ZERO card events; one card event and the exemption narrows straight back to the
birth certificate, so the wall against two real histories does not move. Four
tests in tests/test_topology.py pin it against the real server, and each was
mutation-checked one site at a time — widening the list to any project op,
dropping the zero-cards narrowing, and reverting the list to {created} each
fail exactly the test that owns them.
The ship was the same three steps, in the same order: the wheel installed by
tk-c37061 was copied to
~/taskops-v2-app/rollback/taskops-2.0.0a0-20260809-preupgrade-bffa26.whl and
PROVEN pre-upgrade by content — all 98 taskops/*.py in it md5-identical to
site-packages, carrying _birth and NOT carrying _configuration; the new
wheel was checked for _configuration in taskops/http/ingest.py and for
chapter-goal/markdown-inline in taskops/ui/app.js before it left the
laptop; pip install --force-reinstall out of /tmp/tk-bffa26/, pm2 restart taskops-v2. Verified by CONTENT at the real domain, on a DISPOSABLE board
(scratch-tk-bffa26) and never on a real one: board create + board visibility public + board push — the exact sequence that was refused hours
earlier — landed 3 events at seq 5, and the board is still public with its 2
cards over an anonymous read. Then the wall, live: a SECOND local history
pushed at that now-worked-on board is refused, and it counts 4 events it
never observed — the visibility event has stopped being exempt and only the
birth certificate still is, which is the narrowing itself, observed in
production. The four legacy boards are untouched: board ls reads agenda 35 ·
axion 847 · notas 48 · probe 17, and their four events.jsonl are md5-identical
before and after.
One thing the same incident exposed and this card did NOT fix: Mounts caches
Stores handles, so a board directory removed under a live server stays mounted
until the process restarts. It is noted in _configuration's docstring so the
next reader of that module knows it, and nowhere else.
Not yet running this chapter. §19 is in this tree and NOT on the domain —
proved by asking it, not assumed: a project op=forge call on the axion board is
refused with "op='forge' is not a project fact — this board knows: ('remote',
'visibility')". So the axion board has no forge declared and cannot have one
until the sixth run of Upgrading a host. (The join door that same probe asked
about — it answered "nothing at …" then, from a host running older code — no
longer exists anywhere: §19.1.) Everything §19 describes is true of the code and
of the suite; nothing in it is true of production yet.
§2 says what a report is (a committed file the log points at), §6 what the two tools are, §15 how the dashboard draws one and §16 how the door serves its bytes. What none of them says is the order of the four moves, which is the whole of the workflow and the one thing a reader gets wrong:
1. read the chapter taskops_activity milestone=ms-… depth=full ← a READ, either role
2. write the file .taskops/reports/<name>.html | .md | .txt
3. COMMIT it in your own worktree, like any other file
4. register it taskops_filed path=… title=… sha=<that commit> [milestone=ms-…]
3 before 4 is not tidiness. The event body carries {path, sha} and nothing
else, so registering before committing files a pointer at bytes that are not in
history yet — and the /git door, which resolves the rev and reads git show <sha>:<path>, would answer nothing forever after. The verb cannot check it
(verbs/ never runs git — §14), so the ORDER is what makes the pointer true,
and the refusal wording says "COMMITTED" in capitals for that reason.
Registering the same path at the same sha twice writes nothing and answers
recorded: false: a re-run of a close is not a second report, and an amended
report is a new commit, hence a new sha, hence a new row — which is correct,
because the old sha still serves the old bytes.
This chapter's own report (.taskops/reports/reports-chapter.html, filed against
ms-6f7a24) was written to run all four steps for real. Steps 1 and 4 could not
run, and the reason is structural rather than a defect:
- the host starts one MCP server per session, from the installed
taskops, and it loads once — at session start (.mcp.jsonnames that interpreter); - the code defining
taskops_activityandtaskops_filedis on the chapter's branch. It is not in the installed tool until the chapter lands and that tool is upgraded, and the running server will not see it even then until the session restarts; - the board is deliberately not drivable from a shell, which is the correct refusal and not a gap to route around.
So the tools were unreachable to every actor in the session that built them, the
orchestrator included. The consequence is worth stating once and designing
around: a chapter whose deliverable is an MCP tool has a verification step that
belongs to the session after the landing, not to the close. What the close CAN
prove is everything on the reader's side, and that half was proved — the file is
committed and http/gitdoor.py::answer(), the same call the dashboard makes,
reads it back at that commit with its resolved sha, content_type: text/html and
truncated: false.
That read is also what found the chapter's one defect: gitwork/patch.py::show()
carried its git show <sha>:<path> line TWICE, so every report read forked git
twice and discarded the first result. The answer was correct, which is why no
test could see it — ruff reads a reassignment, pyright reads valid code, and the
behaviour is identical. A suite proves the ANSWER; only exercising the thing
proves the work done to produce it. Removed here, tests/test_topology.py
unchanged and green either side.
The MCP handshake's budget is a cliff, not a slope: hello.CAP is 2900
characters for instructions whole, the panorama gets whatever the protocol
leaves, and under 300 characters of room hello.panorama returns "" and the
session opens blind (hello.py carries the measurement that set the cap). Today
INSTRUCTIONS is 1858 characters, leaving 1040 for the panorama — which is
already being cut, and says so.
INSTRUCTIONS is the ROLE PROTOCOL: it names the tools a role can be refused,
which is what an agent cannot discover by reading a tool list. taskops_activity
and taskops_filed are neither role's alone (§5), and both arrive fully described
through tools/list, so adding them would spend the panorama to repeat something
the host already has. Re-derive the numbers rather than trusting these:
uv run python -c "import re;s=open('src/taskops/mcp/server.py').read();\
print(len(re.search(r'INSTRUCTIONS = \"\"\"(.*?)\"\"\"',s,re.S).group(1).strip()))"Being on a repo's GitHub team should be enough to reach that repo's board. v1 agreed with the goal and paid for it with the mechanism: it had GitHub login — you handed it a token, it kept the token, and every sign-in was another call to GitHub with it. v2 deleted that on purpose, and this chapter brings back the convenience without any of the three things it cost. The whole design is one sentence: GitHub says once who works on the repo, the host enrols their ssh keys from that, and from then on the chapter is invisible.
The chapter shipped in two halves a day apart, and the second deleted the first.
For one day the DEV carried the introduction (taskops join --github: their own
token posted to POST /<board>/join/github, the host asking GitHub whether it
had need on the declared repo). It worked and it was on the wrong side —
§19.1 is the argument. What runs now:
taskops board forge <owner>/<repo> ← the OWNER, on their laptop
│ gh auth token / $GITHUB_TOKEN / a hidden prompt (never a flag)
│ 1. record the fact, then re-READ it out of the answer ← --clear asks nothing
│ 2. GET /repos/owner/name/collaborators?permission=need · paginated
│ the ONLY authenticated call, the token in a header and nowhere else
│ 3. GET github.com/<login>.keys — PUBLIC, no token, no rate budget
▼
POST /rpc members.enroll {members: [{principal, keys}, …]} ← ONE batch
│ login.register per person ← what an invite does, exactly
▼
the dev, in a fresh clone: taskops join → the ordinary SSHSIG challenge
The dev types two words and nothing about GitHub ever reaches them. Their key was published on GitHub before any of this — somebody with push has already got one, which is the whole observation the chapter turns on — so asking them for a second one, or for a token, was redundant twice over.
1. A token that travels. A GitHub token is a bearer for everything that
account can reach; v1 stored one per user, so the board's database became worth
stealing for reasons that had nothing to do with the board. Here exactly one
token is read, on the machine of the person it belongs to, and it goes into the
Authorization header of the collaborator pages and nowhere else: not to the
taskops host at all (members.enroll takes principals and key lines and does not
know what GitHub is), not in events.jsonl, not in server.sqlite, not in
allowed_signers, not in remote.json, not spelled into a refusal —
cli/github.py::_why names the repo and the access level and never the
credential. It is never a flag VALUE either: a secret passed as an argument is in
~/.zsh_history before the process starts and in ps for every user on the box
while it runs, so the sources are gh auth token, $GITHUB_TOKEN, a hidden
getpass prompt, in that order (cli/github.py::token).
That is asserted rather than reviewed, in tests/test_topology.py:
test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere greps the
host's whole tree AND the owner's checkout, with a positive control proving the
scan reads what the flow actually wrote, and checks that <login>.keys carried
no credential; test_the_dev_whose_key_the_sync_published_joins_with_two_words
byte-scans the joined clone afterwards; and
test_no_flag_on_join_takes_a_token_and_none_ever_will holds the parser to it.
2. A network dependency at every login. v1 could sign nobody in while GitHub
was down or rate-limiting, because GitHub was the login. Here GitHub is asked
when the OWNER runs one command, by the owner's own token — so the rate budget
spent is theirs (5000/h authenticated against 60/h anonymous) and never a shared
one this host could exhaust for everybody. After that the credential is an ssh
key and the door is /login; nobody's sign-in touches GitHub, ever, and
test_the_dev_whose_key_the_sync_published_joins_with_two_words pins it by
counting ZERO calls at the stub while the dev joins. When GitHub is unreachable
the sync refuses loudly and enrols nobody — a host whose team could not be listed
does not guess at one — and the fix is to run the same command again, because the
declaration is already recorded when that happens.
3. A second identity system. This is the one that actually killed v1's
version: two kinds of "who", each with its own enrolment, expiry and revocation,
and every feature afterwards written twice. There is no second kind here because
nothing in this chapter creates one. members.enroll calls login.register —
the same function invite/redeem calls — so what persists is a pubkey and a
line in allowed_signers, byte for byte what an invite leaves behind. No session
is minted and no token returned, so there is nothing to expire, nothing extra to
revoke (taskops revoke --key SHA256:… retires a GitHub-enrolled key exactly as
it retires an invited one), and permit has one table.
The one row in core/scope.py is members.enroll, and it is a role rule.
The caller is a PRINCIPAL with a session an ssh key minted — the owner — and
what it decides is who exists on this host, which is key.add's wall and not a
softer one. There is deliberately no join.github operation and no credential
type beside the key: a role table answers "what may this role do", and "GitHub
said yes" is not a role.
forge() is None for every board ever created, and the sync reads it back out
of the answer before it asks GitHub anything — so a board that never declared a
forge, and a board whose owner cleared it, talk to GitHub not at all
(test_the_forge_is_cleared_back_to_invite_only_by_the_same_verb counts the
stub's calls across both). Such a board is invite-only, byte-identically to what
it was before this chapter existed. That is not politeness towards old boards —
it is the only way "GitHub can open a board" is not also "anybody who can name a
repo can open your board". The declaration is an owner's act, {host, repo, need}, refused loudly on every part of it (core/forge.py::declare): an unknown
host, a slug that is not exactly owner/name, a need outside GitHub's own
push/admin. pull is absent on purpose — read access to a public repo is not
a membership. And it is REVERSIBLE: op=forge with repo="" clears the fact,
because opting in is reversible or it is a trap.
Reading the same fact back is asymmetric and that asymmetry is the design:
declare refuses loudly because a human is typing and a typo that defaults to
something plausible hands a board to a stranger; understood refuses SILENTLY
to None, because a door GRANTS on its answer and a value nobody can verify —
an older log, a newer op, a hand-edited file — must read as "no forge" rather
than as a promise the host cannot keep.
op=forge is an owner's act and it has one door a human reaches: taskops board forge <owner>/<name> [--need push|admin], with --clear to make the board
invite-only again. It is a server-scope OWNER operation beside board.visibility
in http/admin.py::REGISTRY — same table, same role gate, no second door — and
since 2026-08-11 that same command SYNCS the repo's team into the host (§19.1).
Declaring it wrote an event on the host and nothing else — correctly, since a
forge is a fact about the BOARD and a committed file would be a second place the
truth lives. But for one chapter the READ side had the same hole the write side
never did: the fact was in the log, forge() read it, the door acted on it, and
it was in no payload. An agent with full board access could not tell that this
board is one whose repo team the owner can sync in, and the dashboard could not
draw what it could not read. Discovery was by bumping into a closed door.
It now rides on the board payload beside visibility, which is the same move
for the same reason (verbs/pulse.py, verbs/project.py): derived per read
from project.forge(stores), one log, one fold, one reader — the payload simply
stopped hiding it. That is also what makes the line above true, that neither
board.visibility nor board.forge needs a get half.
A board that declared no forge sends NO KEY — not null, not {}. The
splat in pulse.py is what enforces it, and it is not a nicety: None would be
a third state (absent / null / a fact) for every consumer to learn, when
core/forge.py spent a whole module collapsing absent, cleared and
unintelligible into ONE answer. Proved rather than assumed — a forge-less
board's whole payload is byte-identical across this change, and
tests/test_verbs.py::test_a_cleared_forge_takes_the_key_out_of_the_payload_again
pins that clearing a forge returns the payload to exactly the key set it had.
The dashboard draws it as one line under the board's own identity in the header
— github.com/cloudacio/Axion · push — text and not an anchor, because
ui/src/links.tsx owns every forge URL the dashboard emits and keys them off
BoardPayload.repo, a DIFFERENT fact that may name a different repo. The
command a reader would need is the line's title
(ui/src/components/chrome/Header.tsx, ui/smoke/sections/forge-opens-the-board.tsx).
The half of the chapter that deleted the other half. Everything below shipped a
day after the door above, and on the second day the door was removed: POST /<board>/join/github, the --github flag, the client's by_github, the token
discovery inside join, and the six tests that pinned them. Deleted, not
deprecated — there is no route answering 410 and no dead flag.
Why the dev's door was the wrong side. It asked each dev to prove a fact the
owner already holds, and charged them a credential to do it: their GitHub token
left their machine, on every first join, to be verified by a host that has no
business seeing one. Berna put it in one sentence — requiring push on the repo
AND a separate ssh key is redundant, because whoever pushes over ssh has already
published the key they push with. So the same question is asked once, by the
person whose token it is, and the dev's side becomes taskops join.
What the deleted tests pinned, and where it went. The token in no file: now
test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere, over a
wider surface (host tree and checkout). The three refusals by name: now
test_github_refusing_the_owners_token_names_the_repo_and_enrols_nobody. An
unreachable GitHub never degrading into a yes: the sync enrols nobody and says
to run it again. A board with no forge never talking to GitHub: counted at the
stub in the --clear test. Membership not being permission to BE somebody: it
cannot arise — nothing enrols on a stranger's say-so any more. And the whole
flow, from both ends, in
test_the_dev_whose_key_the_sync_published_joins_with_two_words.
The seam the owner's side of this chapter stands on, and the one row it takes in
core/scope.py: this is called by a PRINCIPAL with a role — the owner, over
/rpc, with a session an ssh key minted — and what it decides is who exists on
this host. That is key.add's wall and not a softer one because the argument is
a list (http/members.py, admin.py::REGISTRY).
{"verb": "members.enroll", "args": {"members": [{"principal": "ana",
"keys": ["ssh-ed25519 AAAA…"]}]}}
-> {"enrolled": […], "added": [{principal, fingerprint}…], "unchanged": […],
"skipped": [{principal, fingerprint, why}…], "others": […], "signers": 3}
It does not know what GitHub is. It receives principals and key lines; the
forge, the collaborator list and the owner's token live entirely in the CLI that
calls it, so there is no token to store here because none ever arrives. And the
enrolment is login.register — the same function the invite door calls, which
is what keeps "an existing principal only ever GAINS a key" one rule rather than
two: a batch that names the owner leaves the owner an owner
(test_the_owner_re_enrolled_stays_owner_and_the_keys_accumulate, beside the
invite door's own test_a_re_join_never_demotes_the_owner_it_only_adds_a_key).
Idempotent by SKIPPING, not by rewriting. A key already live for its
principal is not written again, so a re-run touches no row and allowed_signers
comes out byte for byte (test_one_batch_enrols_a_team_and_re_running_it_leaves_the_same_state).
Two keys are refused and REPORTED rather than obeyed: a fingerprint the owner
revoked — otherwise the next sync undoes every revocation — and one another
principal already holds, which the fingerprint being the primary key would MOVE,
silently stopping somebody outside the batch from signing. The whole batch is
validated (names and key grammar) before a single row is written, so a typo in
the tenth entry does not leave nine enrolled.
And it revokes nobody. A principal enrolled by invite has no reason to be in
a forge's collaborator list, so a sync that never heard of them would revoke them
for existing. The answer instead carries others — every principal the batch did
not name, with their live fingerprints, which is what taskops revoke --key
takes — and the decision stays a human's
(test_the_answer_names_who_the_batch_did_NOT_name_and_revokes_nobody).
Declaring and syncing are ONE command because they are one intention. The owner
who names the repo means "these people work here"; a second verb to make that
true is a second thing to forget, and the whole point of the chapter is that the
dev on the other end types taskops join and nothing else. So taskops board forge <owner>/<repo> records the fact and then, on the fact re-read out of
the answer (never on its own argv, which is why --clear asks GitHub nothing):
cli/operate.py::_forge declares, signs in ONCE, hands the session down
└─ cli/team.py::sync the flow and the report — knows no transport
├─ cli/github.py::token() gh auth token → $GITHUB_TOKEN → hidden prompt
├─ cli/github.py::collaborators() GET /repos/…/collaborators?permission=push
│ …paginated. AUTHENTICATED, and the only call that is
├─ cli/github.py::keys_of() GET github.com/<login>.keys — PUBLIC, no token
└─ members.enroll ONE batch to the host (§19 above)
The token's whole life is collaborators. It is read on the owner's laptop,
put in one Authorization header per page, and dropped with the frame — not
returned, not printed, not written, and not sent to the taskops host at all.
That is the difference from the door this replaced, where a stranger's token
arrived in a request body: here it never leaves the machine that owns it, and
test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere greps the
host's whole tree AND the checkout's for it, with a positive control so the scan
is known to be reading real bytes.
Pagination is not a detail. A first page is 30 by default and 100 at most,
and a team that outgrows one page is exactly the team this command exists for:
stopping at page one enrols some of them and reports the rest as DRIFT, which
reads to the owner as a revocation list. test_the_forge_enrols_every_collaborator_with_push_across_every_page
puts five people over three pages against a real socket.
Nobody is dropped in silence, and that is the whole report. Three outcomes travel back and each names its own way out:
| what happened | printed as |
|---|---|
| enrolled / already there | enrolled, unchanged, keys N added — from the host's own answer |
| a collaborator with no ssh key on GitHub | named, with taskops invite <login> |
| a principal here who is no longer a collaborator | named, with the exact taskops revoke --key SHA256:… |
The third one is REPORTED and never acted on, for §19's reason: a principal
enrolled by an invite is not a GitHub login, so a pruning sync would retire them
for having been introduced the other way. The owner is excluded from that list
outright — a revoke line beside the account running the command is a way to
lock yourself out of your own host. A login GitHub capitalises is lower-cased on
the CLI side, because a principal name is [a-z0-9._-] and Ana would be
refused mid-batch; a Bot collaborator is dropped by type, having neither a
legal principal name nor a key to publish.
_wire.text() exists for this, and is now the only foreign GET in the package:
it briefly had a sibling decoding into an OBJECT, and neither of the two answers
here is one — .keys is text/plain and the collaborator page is a JSON array,
which as_object flattens to {}. A page of people silently reading as nobody
is the bug that shape prevents, so the decoding belongs to whoever knows what it
asked for and only the status and Unreachable are shared.
The hosted-window chapter (§16, 2026-08-30) added exactly one credential to this story: an ssh deploy key on the host's filesystem, READ-ONLY, so the host could pull a private repo's mirror. The reversal (§16, "The host becomes the remote") inverts the flow, and the key follows: the host now PUSHES to the declared forge, so the deploy key needs write on that one repo. That is a real escalation and it is treated as one, not smuggled in as a config detail:
- It is the owner's explicit act, twice over. Declaring the forge was
already the owner's move (
taskops board forge); minting a WRITE deploy key and installing it on the host is a second, separate decision the owner makes with eyes open, because it hands the host the power to move branches on the forge. Nothing mints it, discovers it, or asks a dev for it. - It is still not a token, and the difference still matters. A deploy key opens ONE repo; a GitHub token opens an account. If the host is ever compromised, the blast radius is the mirror of a repo whose real history the host itself holds — revoke the key on the forge and the leg is dead, the board untouched. Everything §19 bans stays banned: no stored GitHub token, no dev credential travelling, no second identity system.
- Write on the forge, never force. The outbound push is the same best-effort, fast-forward-only discipline the host demands of its own receive door: the mirror leg cannot delete or rewrite on the forge either, so a key with write is not a key that erases.
A board whose owner declines the escalation keeps the read side whole: the
host serves clones, pushes, and the hosted window from repo.git, and the
forge simply goes unmirrored — visible as the mirror-push fact the payload
carries, never as a fault.
Until this chapter a board's life ran one way. board create made one, board push promoted a local history into it, and there it stayed: nothing brought a
board back down, and nothing took one off a host. So the only removal was ssh
plus rm -rf on a directory — which contradicts the design's own line (§5: after
the bootstrap, nothing on a host is ever administered over a shell) and which
destroyed, unguarded, the single copy of a history nobody can regenerate.
taskops init board create+push board pull board rm
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ a LOCAL │ ───▶ │ LIVE on the │ ───▶ │ a SNAPSHOT │ ───▶ │ off the host │
│ board │ │ host │ │ back in here │ │ altogether │
└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘
destroys destroys nothing: destroys nothing: DESTROYS the host's
nothing the local board is the host keeps its board — the only step
RENAMED to .taskops/ board byte for byte in the whole diagram
board.local-<date> and goes on moving that destroys anything
Three modules and one seam: cli/pull.py (the ORDER) over cli/paging.py (the
transport), http/removal.py (the wall) under cli/rm.py, and core/holding.py
underneath both — one pure comparison, two callers, so board pull and board rm
cannot drift about what safe means. Each carries its own argument in its
docstring; this section holds the three a reader would otherwise re-litigate.
Git replication between clones is banned (§11, second row: split-brain, two machines "owning" the same card). A command that brings a hosted history down looks like exactly that ban being broken, and it is the opposite: the ban is what makes it safe to add.
Nothing new was built to move the events. board pull reads the log through the
events verb — the same paged read the dashboard's Event pane makes, by a client
with no more rights than any reader — and tests/test_topology.py::test_pulling_added_no_verb_to_either_registry
asserts board.pull is not a verb this host answers. No cursor is stored, on
either side. No second run is scheduled. Nothing is kept in step. What lands is
a snapshot that stops moving: a card taken on the host a second later is
never in it, and no mechanism anywhere would ever notice the divergence — which
is why the command prints that sentence itself, every time, instead of leaving
it in a document somebody read once (test_a_pulled_board_is_a_SNAPSHOT_and_it_stops_moving
pins the wording AND the fact behind it: the host plans a second chapter, the
local copy still sees one).
That is the whole difference from replication. Replication is a channel with an ongoing promise; this is one read, verified, that ends. Two logs that could both grow and both claim the same cards is the failure §11 names — and a pulled board grows only if a human starts working in it, at which point it is a board, not a replica.
The transfer holds board push's discipline reversed, config flipping LAST:
1 no FOREIGN local history here it would be merged into this one
2 page the whole log down the `events` verb, the one that exists
3 write through `Stores.write` journal → index → fold, the store's order
4 verify EVERY ID arrived a gap STOPS, and the config is untouched
5 only now: board.json goes local; remote.json is left alone
Step 1 is judged BY ID, and that is not a detail. "Is there a local board
here?" is the obvious guard and it is the wrong one: a pull that died after step 3
leaves exactly that, so the retry would refuse itself and the checkout would be
stranded between two boards with no command able to finish the job. What must be
refused is a local history the host does not hold — that one really would be
merged, two histories in one log — so holding.compare decides and a partial copy
of the board being pulled is recognised as the resumed pull it is. And total
does not substitute for the id set: a page silently lost shrinks both sides of
an id comparison and makes it agree with itself, so the length guards the paging
and the ids guard the write.
Step 5 leaves remote.json alone on purpose. The host is still where board create, board push and board rm go, and a checkout made to name a key again
to reach the server it just pulled from would have lost something a pull has no
business taking — which is also what makes the pull → rm sequence flagless
end to end.
The obvious guardrail is are you sure? [y/N]. It was never on the table, and the
reason is worth writing down because prompts are what everything else does.
A prompt asks whether you meant it. Possession asks whether the history
survives you. Those are different questions and only the second one is about
the thing at risk. A human who typed board rm facturador meant it — that is
precisely the state in which a prompt says yes — and none of that establishes
that the 402 events are anywhere else. Possession is checkable, and it is checked
against the board's real ids rather than against anyone's belief.
It also has to be, mechanically: there is nobody to prompt. The removal happens
on the HOST, at the far end of an RPC, in a process with no terminal — a prompt
would live on the client, where it is the weakest thing in this document (see
below). And a prompt cannot be satisfied in a script, which is how a --yes flag
gets born and how it ends up in someone's Makefile.
So the refusal is a possession refusal, and it names both ways out, in the
sentence family cli/commands.py::ORPHAN established for join:
removing 'facturador' would destroy a history nothing else holds: 402 of the
host's 402 event(s) are not here (this copy holds 0) — e.g. 0f3a…, 1b7c…, 4d21…, ….
Two ways forward, and both are explicit:
taskops board pull facturador take the history down first, then remove
taskops board rm facturador --discard-history destroy it anyway; say so out loud
Naming both ways out is the rule, not the politeness: a refusal that only says no is a dead end somebody works around, and the way around is always worse than the way through.
A client-side wall is a convention a hand-written call can skip. board rm
sends the ids its own copy can still read (cli/rm.py::held, through
store.log.read on .taskops/board/events.jsonl, so a quarantined line does not
count as held) and the HOST compares them against what the board really has
(http/removal.py → holding.compare(stores.ids(), held)). Had the CLI compared
for itself, curl would be the way past the only wall in taskops that stands in
front of something irreversible, and nothing would have been logged, refused or
even surprised. A client that fetched the host's ids to judge for itself would be
re-deciding a wall it does not own; the client's whole job here is to say what it
holds and let the owner of the data decide.
The exception is spelled --discard-history, and only a literal true opens it
("false" is refused rather than obeyed). --force stays banned (§11 carries
the row): every tool spends --force on something recoverable, so it names no
consequence and therefore cannot warn. This is the last command in the system
that should be vague about what it takes away, so the flag is named for the thing
it destroys. It is also not an alias — there is no --force in the argument
parser at all, and tests/test_topology.py::test_the_cli_routes_board_rm_and_knows_no_force_flag
is what keeps a helpful future contributor from adding one.
The removal records nothing: the board is the log and the log is gone, and writing the removal into a different board would be one board holding another's history. What survives is the ANSWER — what was removed and how many events went with it, read from the board BEFORE the delete rather than echoed back from the caller's count — because a human deserves to see the size of what they just did while the terminal is still open.
Mounts caches a Stores per board for the life of the process, and sqlite
goes on answering through an UNLINKED file. So shutil.rmtree on a board
directory is not the end of that board: the handle the server still holds keeps
serving the destroyed cache, and board.create on the same name a moment later
handed the DESTROYED HISTORY straight back — the exact route board.ingest's
two-histories wall exists to close, arrived at from behind.
The fix is one call in the right order: Mounts.forget(name) drops the handle
and the watch before the rmtree, and it belongs there because forget is
create's counterpart and _boards lives nowhere else. Pinned by
test_a_removed_board_stops_being_served_by_the_process_that_held_it_open and
test_a_removed_name_is_created_EMPTY_and_the_two_histories_wall_still_stands.
The general shape, which is why it is written here and not only in a docstring: a cache keyed by a path outlives the path. Anything in this codebase that deletes a directory a long-lived process has opened has the same bug waiting, and no type checker and no test of the ANSWER will see it — the answer is correct, it is just the answer to a question about a file that no longer exists.
.taskops/reports/lifecycle-chapter.html is this chapter's report and the five
milestone criteria were run FOR REAL to write it — a real socket, a real ed25519
keypair, real argv through cli.main.main: create → push → rm(refused) → pull →
pull again → rm → board ls → create → push → push(refused). Every one passed.
Step 4 of §18's loop, taskops_filed, could not run, and for the same
structural reason §18 records one chapter earlier, now observed a second time
from the other side: the session's MCP server is the installed taskops, it
loads once at session start, and the installed tool predates the reports chapter
— so taskops_activity and taskops_filed are absent from tools/list even
though both are in this repo's src/. The chapter's own material was assembled
from taskops_card on each of the three cards (the same threads activity depth=full folds) plus git log --numstat, and the reader's half was proved the
way §18 proved it: http/gitdoor.py::answer(), the call the dashboard makes,
reads the committed report back at its sha.
The rule that generalises: a chapter can dogfood any tool the installed
taskops already has, and no chapter can dogfood one it introduced. Registering
this report belongs to the session after ms-b9bf00 puts master on the host and
the tool on the laptop — the milestone that exists for exactly that.
Berna reported hours being "discounted" when chapters close. Diagnosed against
the live log, not guessed: no event is ever lost. events.jsonl only grows
and the land machinery refuses to touch a dirty board file. The complaint was
true, and it was windowing arithmetic plus a missing anchor.
Every figure the dashboard drew was over one sliding window, and
core/hours.py::sessions only counted an interval when BOTH of its stamps were
inside it — report.summary handed it cache.window(start, end), so an
interval that straddled the leading edge arrived with its opener already
filtered out and was counted by NOBODY. A chapter close produces a burst of
events. Days later the window's leading edge crosses that burst and whole
intervals vanish at once, so the total falls by more than the elapsed time.
That is precisely what reads as "hours deducted when a chapter closes".
sessions(stamps, since=) keeps an interval when the stamp that CLOSES it is at
or after the edge, whatever its opener. The caller's one duty is the FETCH:
report._fetch reads from start - hours.GAP, because nothing older than the
longest countable interval can pair into the window anyway. The keeping is
sessions()'s decision, once.
The rule lives in core/hours.py and not in the feeding because sessions()
is the one definition of what an interval is, and the timesheet blocks and the
total beside them must stay one pass — a caller that filtered its own events
would be a second definition, and the blocks would disagree with the total the
moment the two drifted. The counts beside the hours (closed, commits,
cards) stay strictly inside the window: they count EVENTS, not intervals, so
the pre-roll is not theirs to see. tests/test_core.py replays one log through
two adjacent window positions and pins that the sum changes only by real aging.
verbs/_windows.py::parse is the ONE decision of what a window= spelling
means, and every form resolves through core/hours.py so the DST rule holds for
all of them (both edges out of the same zoneinfo walk, never an opening stamp
plus a count of seconds):
7d the last N calendar days, 1..90 — the sliding figure
month this calendar month in the caller's tz, first midnight → today
2026-07 that calendar month, closed on BOTH edges — a figure that never moves again
total the whole log, the figure that only grows
An unrecognised spelling is REFUSED, and the refusal names all four. The
earlier days() fell back to 7 for anything it did not understand, which is how
7dd or august becomes a plausible number nobody questions.
Two edges are argued in the code and easy to get wrong. An OPEN-ended span
(month, total) closes on the next local midnight, never on now: every edge
here is half-open, and the event that closes the current interval is usually the
one stamped now — ending there silently dropped the last block of work from
the very window a person opens to see it. And total carries NO day buckets
(days_total: 0): the whole log grows by a bucket a day forever, most of them
empty, so a truncated tail would be mistaken for the span.
The resolved Span rides on the answer (window in the payload: the spelling
asked, the kind, a printable label, both edges), so a screen titles itself
"August 2026" instead of inferring a month from two epoch floats.
The Actors page opens on the CURRENT MONTH (ui/src/hoursWindow.ts,
DEFAULT_HOURS_CHOICE) with a visible filter — 7 days · This month · Last month
· Total — and the per-actor overlay carries the same span. A sliding window as
the default is what read as hours being discounted.
hoursWindow.ts maps four labelled options onto four server spellings and
repeats none of the arithmetic; everything the screen prints about the span
comes back on ReportPayload.window, because a client re-deriving "August 2026"
from two epoch floats is a second calendar implementation in a second language
and a second zone. ONE spelling is computed in the browser and has to be: last
is "the month before the one the READER is in", emitted as a bare YYYY-MM —
the form the server closes on both edges — and lastMonth(now) takes now as a
parameter so the January case (2026-00) is testable.
The window is an ARGUMENT to the one board call, exactly as milestone is
(ui/src/useBoard.ts) — still ONE fetcher, one coalesced refetch, one snapshot
every pane reads. It is not the per-tab fetcher §15 refuses. The cost is stated:
Throughput draws exactly THROUGHPUT_DAYS bars into a viewBox cut into that
many slots and Actors anchors on a calendar month, so one number cannot be both,
and moving between those tabs costs one request. A board one version behind
sends no window key at all, and windowSaid() falls back to the day-bucket
sentence — a degradation, never a blank.
Berna asked for "an editor inside taskops: the worktrees, a sidebar with the
file directory, the code on the right in tabs, highlighted — the real code of
each worktree in real time, from local". It is the sixth tab
(ui/src/pages/Editor.tsx), read-only by construction, and it answers the one
question the Worktrees page cannot: not what a branch ADDS as a patch, but what
the code LOOKS LIKE right now in the directory a worker is sitting in.
The contract (http/editor.py, same envelope and same token door as /git):
GET /<board>/editor/trees every inhabited directory, the checkout (`main`) first
GET /<board>/editor/tree?tree=<name> files: path, size, mtime, git state · capped, total, cap, seq
GET /<board>/editor/file?tree=&path=[&base=] text or binary, truncated+cap, tracked, base {ref, sha}, marks [[from,to,kind]]
GET /<board>/editor/diff?tree=&path=[&base=] the same file as a unified patch — the Worktrees page's own renderer draws it
GET /<board>/editor/feed?tree=<name> WebSocket/SSE: {"type":"change","tree","seq"} — a SIGNAL, never the files
Only a WINDOW answers. The files live on the disk taskops ui runs on; a
serve-mode host (taskops.bernardocastro.dev) holds boards and at most a bare
repo.git, so it refuses with ONE sentence naming taskops ui
(editor.NO_CHECKOUT) and the page quotes it — never an empty tree.
The security line is gitwork/inhabited.py, three sentences: a tree is a
directory NAME matched before it is joined to anything; a file is resolved on
both ends and refused — never repaired — when it leaves the tree, names .git,
or is a symlink out; and a base ref passes diff.usable, the wall every ref of
the /git door passes. tests/test_editor.py drives every shape through the door.
Live, without a watcher dependency (§11 stands): gitwork/scan.py lists a
tree with git (index + untracked walk honouring every .gitignore, the
directories nobody reads pruned at the directory) and measures it with one
stat per file; http/treewatch.py runs that once a second for a tree
somebody is listening to — watcher.py's lifetime rule — and publishes when
two scans differ. The page re-reads the listing on a signal and re-reads only
the open tabs whose mtime moved, lighting the lines changedSpan names and
keeping the scroll (components/editor/useWorktree.ts). Measured on a
worker's tree in pinecall/v2: a new file in the tree 0.8 s after the write, an
open tab re-rendered 0.2 s after it.
The caps, all stated in the answer: scan.FILE_CAP = 4000 files per tree
(capped + total beside the list), reading.CAP = patch.CAP = 512 KiB per
file and per patch (truncated + cap), binary decided on the first 8 KiB
(a NUL or non-UTF-8) and never decoded. The gutter's base is the card's own
chapter branch through diff.compare_range — the merge-base in flight, the
fork point once integrated — and HEAD for the checkout.
Highlighting is a compact tokenizer, not a dependency
(components/editor/highlight.ts, ~4 kB minified against ~25–45 kB for Prism
or highlight.js with the eleven grammars): keyword, string, comment, number,
tag, attribute — enough to read by, in six --code-* tokens that live in
theme/tokens.css beside the palette rather than borrowing its status colours.
The bundle grew 288 307 → 313 294 bytes for the whole feature.
Berna asked for "un streaming de comentarios en tiempo real … cada progress,
cada comment, se muestra, se highlitean los nuevos, se puede ver el card tipo
popup … al lado del editor … con una excelente organización y agrupación". It
is a third column inside the Editor (ui/src/components/stream/), shut by
default, opened from the one bar that page has.
The socket did not change, and that is the whole design. http/feed.py
publishes {"type":"change","verb","seq"} and nothing else, because that is
what lets a PUBLIC board's feed open with no credential: a watcher learns only
THAT the board moved and re-reads through /rpc, where the anonymous gate
applies again in full. A live comment column is exactly the feature that would
have tempted a payload onto that frame, and the day a message carries a card
that door has to close with it. So: the socket pokes, the page reads.
What DID change is the direction the log can be read in. events used to
answer backwards only — newest-first pages on a before= cursor — so "what
arrived since I looked" was a thing the client had to DERIVE: head - lastHead
rows off page one, plus a set of already-seen ids as the safety net for a burst
larger than a page (ui/src/components/toasts/model.ts is the post-mortem of
that arithmetic, and it is exact only until it is not).
events → the newest page next = the older cursor
events before=<seq> → further back next = the older cursor
events after=<seq> → what arrived since, next = carry on from here
OLDEST first (a full page means more)
next means the same thing in both directions — the cursor for the page you
have not read yet, in the direction you are reading — and the two cannot be
asked at once, which is two questions and is refused as one. The SQL is
store/cache.py::since, which replay already used, given a limit: a
catch-up is capped on the OLDEST end, which is the one thing a descending
page() cannot do.
The cursor a live reader carries is head, not a seq per row. The rowid is
still dropped on the way out (verbs/events.py:53 was and remains the shape).
A reader keeps the head of the answer it read and asks after=<that head>;
what comes back IS the news, by construction — no arithmetic, no dedupe, and no
way to be wrong when six hundred events landed while the tab was asleep, because
then it pages forward on next and, past CATCH_UP_PAGES, starts over on page
one rather than drawing a list with a hole in the middle.
Still ONE read of the log. useEvents is called in App, once, and three
surfaces are handed the result — the Monitor's Event stream, the comment toasts,
and this rail. The change is that it CATCHES UP instead of starting over: new
rows are prepended, the reader's scrollback survives, and a quiet board answers
with an empty list instead of fifty rows on every poke.
What arrived is stamped in the CLIENT's clock, never in Event.ts. A
comment written three minutes ago that reaches this tab now is new HERE, and a
rail that highlighted by ts would show it already faded — toasts/model.ts:: Toast.shown decided the same thing first, for the same reason. The stamps are
pruned to a minute, so the map is bounded by what arrived, not by how long the
tab has been open. Nothing is stored: there are no read-receipts on this board
and there is no mark-as-read verb, by design (§11), so "new" is arithmetic that
dies with the tab.
The organisation is a MOMENT, not a row. The Monitor's pane draws one row
per event and is right to: it answers "what happened, in order, ever". A 320px
column beside the code answers "what are the workers doing right now", and at
that question one row per event is noise — a taskops_plan of nine cards is
nine identical rows. So consecutive events by the same actor on the same card
within MOMENT_GAP_S (120s) fold into one entry, counted, sized and ended with
the worker's own number: 2 commits · 2 files · +41 −7 · progress → 68. The
fold is on (actor, task) because a worker moving between cards is the change a
reader wants to see, and it is CONSECUTIVE-only, so an entry can never be
hoisted above events that happened after it. Files are counted per PATH across
the run — one file touched in three commits is one file — through
Thread.tsx::changed, which is the Event stream's own numstat fold given a
list; a second definition of "how much changed" is the drift format.ts is the
post-mortem of.
Three ways to narrow, none of them a fetch: five families (talk work
code review chapter, every kind in core/kinds.py in exactly one), one
card followed by clicking its id, and older for history. progress needs no
branch anywhere — it is an edited event (verbs/update.py::_progress argues
why it is not a kind of its own) and the roll-up reads it off the body.
The reader's place is never taken. New entries land on top; a reader who has
scrolled down into history does not have the list move under them, and a pill
says how many are waiting above. The card popup is openCard — App's one door
into the Drawer, which is mounted once over whichever page is on — so the rail
draws no dossier of its own and the same card opens identically from the Board,
the Monitor and here.
What the headless harness does and does not pin
(ui/smoke/sections/stream-rail.tsx): the fold, the roll-up, the numstat, the
freshness tiers, the merge, the arrival stamps, the narrowing, the entry markup,
the three empty states and the Editor's third column — all pure functions or
Stream, which renders under react-dom/server with no client, no socket and
no timer. Not pinned, and said rather than faked with a jsdom: the scroll
position, the one interval, and the catch-up walk. The after= half is pinned
on the server, where it is decided (tests/test_verbs.py), including the
burst-larger-than-a-page case the client arithmetic could not survive.
The bundle grew 330 231 → 341 411 bytes for the whole feature: the model, the rail and the Editor's third column, no dependency added.
static.payload re-reads the bundle from disk on every request, and its
docstring says why: taskops ui is a developer's own process and a cache would
serve the previous build after every node ui/build.mjs. That rule was only
carried half way. The response went out with Content-Type and
Content-Length and NOTHING else — no Cache-Control, no ETag, no
Last-Modified — while the URL (/app.js, one name, three mounts) never
changes and the content changes on every build and every upgrade.
A browser handed neither a freshness signal nor a validator applies its own heuristic, and can go on serving the build it already has — silently, and without ever reaching the process again, so restarting the server does not fix it and neither does upgrading the wheel. It cost an afternoon of "I just installed it, where is it".
http/page.py::deliver is the whole fix and it lives there because that module
is already "how a page is answered": no-cache (which is ask me first, never
do not store) plus a content-addressed ETag, so a reader who already holds
the build gets a 304 and no body and the correctness costs one round trip
rather than 345 KB. The tag is a hash and not an mtime on purpose — a wheel is
unpacked afresh on every install, so mtimes move whether or not a byte did, and
a validator that lies in THAT direction is worse than none. A refusal (410
NO_UI, 404) carries neither header: caching one of those past the push that
ends it is the same bug pointing the other way.
tests/test_window.py::test_the_bundle_is_served_so_a_browser_cannot_keep_a_build_you_replaced.