# Architecture — tower-do

> Long-lived system-boundary + key-data-flow facts. Field-level contracts live
> in [CONTRACTS.md](./CONTRACTS.md); decisions (incl. why) in
> [DECISIONS.md](./DECISIONS.md). README links here for orientation.

## System boundary

tower-do is a **Pi extension** (registered tools + above-editor widget + context
reminders) whose only durable state is a **plain append-only JSONL file** per
project:

```
~/.pi/tower-do/<project>/board.jsonl   # <project> = slug-hash of the git root
```

- **No daemon, no DB, no lock server.** Any Pi session or subagent that can read
  the file sees the same tasks. This is *file-as-state* (Kimi Tower blackboard
  lineage): state = storage, communication = storage.
- The **disk file is the single source of truth.** In-memory state (widget /
  reminders / `currentView`) is a display cache only. If the workspace is
  cleaned, the last session checkpoint (a custom context entry) is the fallback
  display source — pinned to `revision 0` so it never advertises a stale
  `baseRevision` the disk gate would reject.
- Runtime deps (`typebox`, `@earendil-works/*`) are peer dependencies supplied
  by the Pi host — the package adds no install-time deps of its own.

## Event log & folding

Three event kinds are appended (one JSON line each), each with distinct fold
semantics:

| kind | fold | write path |
| --- | --- | --- |
| `task` | LWW by `key` (key total order; full-replacement snapshot) | `tower_do` |
| `message` | append, LWW by `id` for re-emissions (acks) | `tower_do_talk send` / `inbox` |
| `finding` | LWW by `id` + status machine | `tower_do_talk finding` |

- **`revision` counts only task events** — message/finding traffic never makes a
  peer's legal `baseRevision` stale (communication is "free").
- Every write path re-folds **inside** `withFileMutationQueue`: revision guard +
  diff both see the freshest events, eliminating read-then-write races. Appends
  are per-process serialized on a promise chain with single-write `O_APPEND`
  syscalls; cross-process writers rely on the `baseRevision` gate (last-writer
  wins otherwise). `fold()` treats a missing file as an empty board; any other
  read error throws (an unreadable file must not look like a cleared board).
  `rawLines()` / `rawTail()` always throw on I/O error — display callers
  degrade; the stale-owner gate reads `rawLines()` (full log) so a bounded
  tail can never misreport an active owner as idle.
- The revision a write returns **exactly equals a re-fold** of the file
  (one bump per task event), so a caller can verify the write landed.

## Project anchoring

The board is shared per **project** = nearest ancestor git work-tree root (a
`.git` directory, or a worktree/submodule whose `.git` is a file starting with
`gitdir:`); when no git boundary exists, the directory itself is the project.
`config.json` is global (`$HOME/.pi/tower-do/`), so `identity` applies across projects.

## Liveness sidecar (not board events)

The widget's `live N` does not read the board log: each running session owns
exactly one file `~/.pi/tower-do/<project>/live/<identity>.<sessionId>.json`
(`{"identity", "at", "aliases"?}`), rewritten on a 30s heartbeat and deleted
on clean exit. The count is a pure read derivation (`liveSessionCount` in
`state.ts`): distinct identities with a record fresh within 2 minutes, plus
self. Optional `aliases` are extra owner labels the session has written as
(`as: "coder-1"`): they populate the stale-owner gate's liveness set (a
parent still heartbeating protects its subagent's claim) but never inflate
`live N`. Own-file snapshots are serialized per session, so an older write
cannot land after a newer one and drop an alias. `fs.watch`
on the sidecar dir and `board.jsonl` (debounced ~250ms, session-scoped) makes
peer enter/exit — and peer board writes, which also re-fold the view — visible
within one tick. The board log keeps exactly task/message/finding events:
liveness never churns `revision` or the activity feed, and directories without
a board file get neither a live segment nor sidecar writes.

## Read derivations (never written)

Several board views are **pure read derivations** over the folded view / raw
activity tail — they never mutate the file, and callers cannot "write" them:

- **Presence** (`Who is around`): per-identity last-seen from parsed activity
  lines; three states — active / `⚠ idle` (>10 min quiet) / (not started).
  Real idle owners of unfinished tasks are footnoted on `tower_do` receipts.
- **Stale owners** (`staleTaskOwners`): owner labels whose last board activity
  (or, for a never-active owner, the task's `updatedAt`) is older than
  `OWNER_TAKEOVER_MS` and who have no fresh sidecar heartbeat/alias. Feeds the
  owner-guard's takeover exception (contract in CONTRACTS.md) — a permission
  gate, not a display: `tower_do` reads the full activity log and the sidecar
  dir, and any read failure yields no stale owners (strict guard).
- **Block reasons**: `taskIsBlocked` is a read derivation over explicit
  `status: "blocked"`, the persisted `blockedBy` field, and unresolved
  `dependsOn`. `findAllUnresolvedDeps` only lists the dependency reason.
  `status: "completed"` is never blocked (stale `blockedBy` does not count).
- **Scope conflicts** (P1): `findScopeConflicts` derives two advisory signals
  from `scope` declarations + `changedFiles` receipts:
  1. **overlap** — a completed task's receipt file lies inside an in-progress /
     pending task's `scope` glob (planner may be about to touch what a peer
     already changed);
  2. **collision** — two in-progress tasks declared intersecting `scope` globs
     (a planning mistake worth surfacing before both start writing).
  Advisory only — never a gate (scope is self-declared, changedFiles is
  self-reported; authoritative conflict resolution needs git-diff reads, out of
  scope for TowerDo).
- **Message retention**: `retainMessages` retires *fully-read* history past the
  internal message-retention budget; unread / partially-read messages never exit, and
  orphaned broadcasts (audience all gone) retire so finished rounds don't pin
  the budget forever.

## Layout

```
index.ts     # extension entry: 3 tools + widget + reminder + lifecycle
state.ts     # pure schema / validation / fold / read derivations (no I/O)
board.ts     # disk layer (append-only JSONL) + config normalization
git-count.ts # widget dirty/session file-count derivations (no I/O)
test/        # pure-logic + smoke suites (see CONTRACTS.md gates)
docs/        # this documentation set
```
