Metadata-Version: 2.4
Name: megillah
Version: 2026.9.16.95
Summary: A planning technology for sustained, nonlinear work with AI collaborators
License-Expression: AGPL-3.0-or-later
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business
Requires-Python: <3.15,>=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: click<9,>=8.1
Requires-Dist: fastembed<0.9,>=0.8
Requires-Dist: httpx2<3,>=2.5
Requires-Dist: huggingface-hub<2,>=1.28
Requires-Dist: mcp<3,>=2.2
Requires-Dist: numpy<3,>=1.26
Requires-Dist: pyyaml<7,>=6
Provides-Extra: dev
Requires-Dist: build<2,>=1.3; extra == "dev"
Requires-Dist: jsonschema<5,>=4.26; extra == "dev"
Requires-Dist: mypy<2,>=1.20; extra == "dev"
Requires-Dist: pytest<10,>=9; extra == "dev"
Requires-Dist: ruff<0.16,>=0.15; extra == "dev"
Requires-Dist: setuptools>=77; extra == "dev"
Requires-Dist: types-jsonschema<5,>=4.26; extra == "dev"
Requires-Dist: types-PyYAML<7,>=6; extra == "dev"
Requires-Dist: types-setuptools<81,>=80; extra == "dev"
Dynamic: license-file

# Megillah

Megillah is a planning technology for sustained, nonlinear work conducted with
AI collaborators. It keeps purposeful work — its framing, requirements,
commentary, evidence, and history — in one durable record that any client can
pick up cold.

The product argument and the domain that supports it are documented
separately:

- [Product thesis and design rationale](docs/product.md)
- [Domain specification](docs/domain.md)
- [Operating the Workspace store](docs/operations.md)

## Using the CLI

Create a Workspace store with its first Project, then frame work into it:

```console
$ megillah init --key MEG --name "Megillah" --description "Why the Project exists"
$ megillah frame "Draft the survey instrument" --project MEG --description "What done means here"
Framed MEG-1: Draft the survey instrument
$ megillah require MEG-2 MEG-1        # reads: MEG-2 requires MEG-1
```

By default the CLI uses `~/.megillah/megillah.sqlite3`. Pass `--store` or set
`MEGILLAH_STORE` to use another Workspace store.

A Workspace holds its Projects side by side. `project` adds another to the
store and `projects` lists them:

```console
$ megillah project --key TEM --name "Temenos"
$ megillah projects
MEG  Megillah: Why the Project exists
TEM  Temenos
```

A command acting in one Project as a whole names it by its key, with
`--project` or `MEGILLAH_PROJECT`: `frame`, `available`, `search`, `tracks`,
`track`, `revise-track`, `dissolve-track`, `order-tracks`, `revise-project`, and
`artifact` recording an Artifact for the Project itself. Naming none is
refused, even in a Workspace holding one Project, so a command that works
today keeps working when a second arrives. A command naming an Assignment,
Comment, or Artifact takes no Project: the identifier already says which
Project the record belongs to.

`init` also acquires the search models: about 219 MiB in total, with
its progress shown, into `~/.cache/megillah/search` (or under
`$XDG_CACHE_HOME`). Without the network the Workspace is still created and
`init` says that search waits; `serve` or `search` acquires missing models the
next time one runs online.

`init` ends by saying what to do next: the `serve` command for the Workspace
it created; where the browser will be; and how to connect Claude Code, Codex,
and Claude Desktop to that instance (see [Connecting an
agent](#connecting-an-agent)). The command runs this installation's
`megillah` by its absolute path, as the agents do, since a `megillah` found
on `PATH` may be absent or another installation. It names the store by its
absolute path when it is not the default, and writes in any serving option
set in the environment (`MEGILLAH_HOST`, `MEGILLAH_PORT`,
`MEGILLAH_ALLOWED_HOSTS`), with the addresses following them, so it serves
the instance the agents are pointed at from any directory and any shell.

Search runs the models through ONNX Runtime, which ships for CPython 3.12 to
3.14 on glibc Linux (x86-64 and aarch64), macOS 14 or later on Apple
silicon, and Windows (x86-64 and arm64). Megillah installs only where it
does: not on Intel macOS or musl Linux.

The working loop revolves around three questions:

```console
$ export MEGILLAH_PROJECT=MEG
$ megillah available                  # what can begin now?
$ megillah begin MEG-2                # begin it, and get situated
$ megillah inspect MEG-2              # how did we get here?
$ megillah search "survey evidence"   # where has this come up before?
```

`begin` marks the Assignment active and prints its continuation context as
the Brief: its facts and description, then an index — prerequisites and
dependents by name, where prior collaboration lives, what commentary,
evidence, and activity exist, and how to pull each string. `inspect` is the full pull, bodies
included, with the Assignment's activity — the running account of what
happened to it — in recorded order. `continue` re-situates active work without
changing anything. Work ends with `deliver`, `abandon` (which, like
`icebox`, takes several Assignments as one change), or
a trip to the icebox (`icebox`, `thaw`); `reopen` takes a resolved
Assignment back. Framings are revised with `reframe`.

Work discovered mid-flight is placed into the shape of the plan by `join` and
`insert`, each landing its whole compound change or none of it. `join` puts an
Assignment into a Track — moving it if it already belongs to one — as a
prerequisite or a dependent of an Assignment already there; `insert` puts one
between two Assignments, replacing their Dependency with two. Both name the
Track: neither infers it from the Assignment it is placed against, so a
cross-Track placement stays a deliberate choice.

```console
$ megillah join MEG-5 MEG-3 --role prerequisite --track Foundation
MEG-5 joined MEG-T1 as a prerequisite of MEG-3
$ megillah insert MEG-6 MEG-3 MEG-4 --track Foundation   # between MEG-3 and MEG-4
MEG-6 inserted between MEG-3 and MEG-4 in MEG-T1
```

The rest of the graph-shaping board's operations are on the command line too,
each again whole or not at all. `place` puts one or more Assignments in a
Track, keeping their Dependencies, and relates them where they land when given
`--anchor` and `--role` or `--between` a Dependency's two ends; `rehome` moves
one Assignment, striking the Dependencies it can give up for the ones its
destination implies; `detach` removes every Dependency an Assignment takes part
in; and `require-together` adds an exact set of them. `arrange` and
`order-tracks` keep where cards sit and how Tracks stack, which is presentation
only.

```console
$ megillah rehome MEG-7 --track Delivery --anchor MEG-4 --role dependent
Placed MEG-7 in MEG-T2, removing MEG-3 → MEG-7, adding MEG-4 → MEG-7
$ megillah order-tracks Delivery Foundation
Ordered the Tracks: MEG-T2, MEG-T1
```

Durable context accumulates alongside the graph: `comment` for commentary,
`artifact` for evidence, outputs, and locations such as a pull request or a
shared conversation (`promote` moves one to the Project while preserving its
origin). An Artifact's material is text given with `--material` or a file of
up to 10 MB given with `--file`, which Megillah then holds beside the store;
`revise-artifact --file` replaces it, and `material` writes a held file back
out:

```console
$ megillah artifact MEG-7 --description "Wave 2 responses" --file wave2.csv
Recorded MEG-A3 on MEG-7
$ megillah material MEG-A3 --output ~/Downloads/wave2.csv
Wrote MEG-A3's file to /home/you/Downloads/wave2.csv
```

`search` spans visible and iceboxed, open and resolved Assignments. It searches
the current framing, Comments, Artifact text material, file names, and
locations, and activity. A held file's contents are not searched. Results
identify the matching source and show its excerpt; Assignment state is shown
but does not influence relevance. A title
the Assignment once carried is still found, because the account records it; a
description it once carried is not recorded anywhere and is not found.

Retrieval is hybrid and entirely local: the query as a literal phrase,
BM25 over SQLite's full-text index, and cosine similarity over a pinned,
quantized Nomic Embed Text v1.5 model, fused by reciprocal rank to select
up to 20 passages. A pinned MiniLM cross-encoder, run directly through
FastEmbed, then ranks these passages and withholds weak matches. For nonliteral
matches, a shorter displayed excerpt must independently clear a stricter
relevance cutoff, so incidental provenance alone cannot support admission.
Identical texts share inference; 20 candidates require at most 40 scored texts.
Literal phrase evidence remains discoverable beyond that bounded model input. A
literal phrase is the whole query in any case, neither beginning nor ending
inside a word or number: `port` is not found in "report", nor `MEG-7` in
"MEG-70".
Results are a limited selection, not an exhaustive account of relevant work;
the relevance cutoff can still admit adjacent topics or miss useful records.

A query consisting of an identifier under the searched Project's key returns
only that record or exact mentions of it, without semantic substitutes. Queries
with other prefixes, such as `ISO-9001` in Project `MEG`, use relevance ranking.
A Comment mentioning an identifier that has never been minted can therefore
match, attributed to the Assignment holding that Comment; it does not assert
that the mentioned record exists.
Excerpts quote source text, centering literal phrases. An activity excerpt
quotes the statement without its leading moment and Client unless the query
occurs literally in them; the model gives no attribution, so a semantic match
on the Client alone is quoted by its statement.

Record text never leaves the machine; model files are the only thing ever
downloaded. The application offers retrieval the eligible records as values
and accepts back only references it can verify against the same Workspace
snapshot. Vectors for unchanged text are kept in a rebuildable cache beside
the models, one index per Project, private to the user; deleting the
cache loses no Workspace fact and costs one re-embedding and, for the models,
new downloads. The index keeps only what the corpus it was last offered
needs, so Projects sharing one would re-embed each other every time a User
searched them in turn.

The first search loads the models and embeds any uncached text; later searches
embed only new or changed text. Preparation currently happens during the search,
not in the background. Restarting the listener reuses the vectors when the
Workspace path, Project, model, and text are unchanged. Local embedding inference
uses four threads and relevance inference uses two, so each listener has a
bounded inference thread budget even on a machine with many cores.

Every protected open runs the store's defenses: an integrity check, a refusal
of any store whose schema this build does not use as it stands, and a small
ring of proved ordinary backups. No command migrates a store as a side effect:
`megillah store migrate` does, behind a proved backup, with every listener over
that store stopped. Record reads over HTTP open the store read-only
and run none of the defenses; the server takes one protected open before it
starts, and each mutation command takes its own. The `megillah store` group
(`status`, `check`, `migrate`, `backup`, `backups`, `restore`) is the on-demand
surface; [Operating the Workspace store](docs/operations.md) is the runbook.

Every command accepts `--help`.

## Serving the Workspace

`megillah serve` is one Megillah instance: one process on one port, serving
the browser at its root and the records and commands that browser reads and
performs under `/api` on that same origin.

```console
$ megillah serve
Warning: this HTTP boundary is unauthenticated. Anything that can reach it can read and write the Workspace record.
Serving /home/meg/.megillah/megillah.sqlite3 at http://127.0.0.1:8642
Serving the browser at http://127.0.0.1:8642
Reached at http://127.0.0.1:8642
```

The instance uses the store chosen at launch (`--store` or `MEGILLAH_STORE`)
and binds to the loopback interface by default. `--host` (or `MEGILLAH_HOST`)
chooses the interface it binds, a wildcard exposing it onward; `--port` (or
`MEGILLAH_PORT`) chooses its port; `--allowed-host` (or
`MEGILLAH_ALLOWED_HOSTS`, comma separated) names the hostnames it answers to
beyond loopback, such as a machine name on the local network or a tailnet;
and `--no-browser` serves the boundary alone. An instance bound to every
interface refuses to start until it is given its hostnames, because it
answers requests by the hostname they address and a wildcard names none.
The console gives such an instance's browser its loopback address, beside the
hostnames it answers to, since a request addressed to `0.0.0.0` is refused. An
allowed host is a hostname or IP address alone, as a request addressed to it
names it; one given with a scheme, port, or path could never be matched, so
it is refused at startup.

Unless the instance is [guarded by a shared secret](#guarding-the-boundary-with-a-shared-secret),
nothing authenticates a request, so the console warns before the boundary
can be reached.

Once it is answering, the instance follows each URL it answers at — the
address it is bound to, or loopback when bound to every interface, and then
each allowed host at its port — as a client would, and says whether it was
reached there. A URL it was not reached at is named with the first stage that
failed:

- **DNS**: the name does not resolve.
- **connection**: nothing accepts connections at any address the name
  resolves to.
- **host admission**: another server at that address refuses the host the
  request named. This instance answers every name it checks, so a refusal
  there comes from something else.
- **application**: what answered was not this instance answering from its
  Workspace — a failure, another program, Megillah at another release, or
  another Megillah instance altogether. The check knows its own requests
  arrived by a token only this process holds, so even an instance serving a
  copy of the same Workspace is told apart.

```console
Not reached at http://megillah.local:8642 (DNS): megillah.local does not resolve: Name or service not known
```

The instance serves either way, since a name can come to resolve after a
service starts. These checks are made from the machine the instance runs on,
so they cannot see a firewall between it and another machine, or a name as
another machine resolves it.

A request addressed to a host the instance does not answer to is refused
`421`, saying which host it named and that `--allowed-host` names it, so
reaching an instance by a name it was not given says what to add.

Because the page and the boundary it commands share one origin, a page is
admitted only when it came from that very origin — the same host and port.
A page from anywhere else is refused, including one on another port of the
same machine: sharing a machine earns a page nothing here. A caller that is
not a browser sends no origin at all, so the CLI, `megillah mcp`, and curl
reach every route. The browser and the listener serving it are one build of
one release, so nothing is negotiated between them; every answer still names
that release in `X-Megillah-Version`, as information.

The browser is built into the package when a distribution is built, so an
installed Megillah carries it. A source checkout that has never built it —
one with no Node installed — serves the boundary exactly as above and says
on the console that the browser is not served.

One Workspace holds several Projects, so `GET /api/projects` names every
Project it holds and everything a Project answers is read under its key, as
JSON: `/api/projects/<key>` (that Project's facts and the Tracks in use),
`/api/projects/<key>/assignments` (its visible Assignments with derived
`available` and `blocked` conditions),
`/api/projects/<key>/assignments/search?text=<query>&limit=<count>`
(Assignment discovery, each result carrying the Assignment's current facts
and the bounded evidence supporting its rank),
`/api/projects/<key>/dependencies` (every edge), and
`/api/projects/<key>/resolutions` (resolved visible work at its effective
resolution moment). A key the Workspace does not hold answers `404`.

`/api/assignments/<id>` answers one Assignment's full continuation record:
its identifier already names the Project that minted it, and a second
account of where it belongs could only disagree with the first.

Every response carries the release the listener loaded in
`X-Megillah-Version`. A caller may send that header too: it requires exact
release equality. After host, origin, and secret admission, a request naming
another release receives `412 Precondition Failed` with `release-mismatch`
before dispatch, retry-key handling, or material storage. The request was not
performed. A caller sending no release header accepts the listener's release
and is served normally. The browser, being part of the same build as the
listener answering it, sends none; the CLI operates on its local store and
does not negotiate with a listener.

`megillah mcp` names its release on every request and checks `/projects`
before every write. A different release found by that read stops the write,
even when the listener predates request checks. The write itself still carries
the precondition, since a listener can restart between requests. MCP refuses
answers from another release before interpreting their bodies. A mismatched
write answered without HTTP 412 is
`outcome-unknown`: the caller must inspect the record before retrying and keep
the original idempotency key and arguments. HTTP 412 confirms refusal before
dispatch; `release-mismatch` on an MCP command therefore means the command
was not performed. Install the listener's release for the MCP server and
reconnect it. An answer without a release header remains `not-megillah`,
identifying a proxy or another service rather than a different Megillah build.

Record reads open the store read-only — they can never mutate or migrate a
Workspace — and CLI writes made while the server runs appear in subsequent
reads.

`/api/projections/arrival` answers the whole Workspace at once, since it is
what a person or collaborator arriving sees before choosing an undertaking:
every Project in the order it was created, read from one state of the store
and each carrying its own revision, with its framing and Tracks, the latest
activity of its visible work, its active work grouped by Track, every
Assignment that can begin, and how many are blocked. Each active Assignment
carries the moment it goes quiet, a week after its latest activity.

The planning projections of one Project are answered under
`/api/projects/<key>/projections/`, each stamped with that Project's
revision so a renderer can tell answers from one state apart from answers
from another — and so that a page open on one Project is not made stale by
work in another: `temporal` (resolution history beside the
open structure, with `?focus=<id>` emphasizing one objective and its
prerequisites), `structure` (the connected bodies of visible work, with
`?selected=<id>` exposing one body in full), `curation` (one attention scope
with its three measures and, on each row, what applies to that Assignment
now, under `?scope=`, `?track=`, and `?selected=`), `placement` (where work
is placed: the board of Tracks), and `sequencing` (the order in which work
must proceed, with `?history=true` revealing the resolved work before it).
The placement projection carries the board's whole state at once — Tracks in
their kept order with visible and parked members, the Track order sequencing
derives as `sequenced`, the connected bodies of untracked work, every
connected body of visible work with the members iceboxing it would park as
`parks` (its undelivered work, null when that icebox would be refused), every visible Dependency with whether it can still be removed, each
card with whether it can be detached and, as `icebox_detached`, the
Dependencies iceboxing it with its removable ones struck would strike (null
when that icebox would be refused), the kept card positions, and what a
newly framed Assignment could relate to. Given a subject, `?assignment=<id>`
for one Assignment, parked or not, or `?body=<id>` for the untracked body
holding an Assignment, it also answers `targets`: every accepting spot and
every refusing card side. Each target has `at`, `offers`, and `conflicts`.
An offer is an exact command document with the change it makes, every
Dependency it adds saying whether it joins two Tracks. Offers and refusal
facts come from rehearsing the shaping operations themselves against the
current record, so what the board offers is what the boundary accepts at
that revision. When no change accepts at a card side, its `offers` is empty
and `conflicts` names the distinct refusing facts in the same
`{record, fact, detail}` form as a command refusal. An accepting target has
empty `conflicts`; other kinds of spot with no accepted change are absent.

The sequencing projection carries every Track as `tracks`, in sequencing's
order, each with `counts` of its `visible` members and those still `open`,
and the same `counts` for trackless work as `trackless`; neither counts the
Icebox or changes with history. Each Track's `dissolution` is `null` when the
browser offers none, an empty list when the Track is empty, or the exact
open, iceboxed Assignment records whose release must be confirmed. These
facts are independent of which rows or history the page shows.
Visible open work and every abandoned Assignment one of them requires make
up the order, as `assignments`. Asked
for with `?history=true`, the rest of the visible resolved work comes as
`history`, which is otherwise `null`. Each Assignment has its `position`: in
the order, the length of its longest chain of prerequisites within the order
in any Track; in history, a negative place counted back from where the order
begins, `-1` for work nothing in history requires and a further place back
for each Assignment in the longest chain of its dependents within history.
Revealing history moves nothing in the order and leaves `tracks` in the same
order. Each also has its `body`, the least-numbered member of its connected
body among the work carried, and, as a board card does, its `operations`, the
Dependencies at it that are `removable`, and whether it is `detachable`. The
`dependencies` are those among the work carried, each saying whether it is
`unsatisfiable`, which open work waiting on an abandoned prerequisite is;
every other Dependency is left out.

The optional `?focus=<id>` names an Assignment in the order or revealed history.
It combines with `history=true`. The answer's
`focus` identifies that objective and `emphasized` names it and its transitive
prerequisites among the cards shown. Reach is computed through the full graph;
focus changes neither the sequence's contents nor its positions. An objective
outside the sequence answers `not-found`.

The optional `?assignment=<id>` names an Assignment being carried, as it does
for the placement projection, and the answer's `subject` and `targets` take
the same form. Sequencing carries one Assignment at a time, so it names no
`body`.

The continuation record at `/api/assignments/<id>` likewise states what applies
to the Assignment now: `operations`, the lifecycle and framing operations
that would not be refused, `revisable`, the framing fields a reframe may
revise, `removable`, the Dependencies at the Assignment an unrequire
would strike, and `withheld`, each lifecycle operation that would be refused
with the conflicts refusing it in the same `{record, fact, detail}` form as a
command refusal, so an inspecting surface offers exactly what applies and
explains the rest in the rules' own words. Curation rows carry the same
`operations` and `withheld`. The record's prerequisites and dependents also
carry their own availability and blocking. The placement projection does the
same for framing choices, parked members, and outside endpoints of crossing
Dependencies, keeping state readable wherever the browser identifies the
work.

Discovery uses the same retrieval as `megillah search`. `serve` acquires the
model before it listens if it is not already there, so the download happens
at the console rather than inside a request; without the network it says
so and serves anyway. Search is the only route that depends on the model:
every other record stays readable while it is absent. A search made without
it answers `503` under the `unavailable` code — the capability is absent,
which is not the same as the `404` that answers a request for a record that
does not exist. One search is answered at a time, so a search arriving while
another is underway is turned away as `busy`, also `503`; that one is worth
retrying rather than a sign anything is wrong. A query matching nothing is a
success with an empty result list.

Mutations are performed by `POST /api/commands/<name>`, whatever Project
they act in, one command per application operation under its CLI name:
`frame`, `reframe`, `begin`, `continue`, `set-aside`, `deliver`, `abandon`,
`reopen`, `icebox`, `thaw`,
`require`, `unrequire`, `require-together`, `detach`, `join`, `insert`,
`place`, `rehome`, `arrange`, `order-tracks`, `comment`, `revise-comment`,
`artifact`, `revise-artifact`, `promote`, `track`, `revise-track`,
`dissolve-track`, `project`, and `revise-project`.
A command document is JSON with two members —
`attribution` naming the `client`, and `arguments` carrying the operation's
facts:

```console
$ curl -s http://127.0.0.1:8642/api/commands/frame \
    -H 'Content-Type: application/json' \
    -d '{"attribution": {"client": "curl"},
         "arguments": {"project": "MEG",
                       "title": "Draft the survey instrument"}}'
{"assignment": {"id": "MEG-1", ...}}
```

A command whose arguments name no record already belonging to a Project
carries a `project` argument naming one by its key: `frame`, `track`,
`revise-track`, `dissolve-track`, `order-tracks`, `revise-project`, and
`artifact` when it names no owning Assignment. The `project` command instead
gives the `key` a further Project will carry, with its `name` and optional
`description`, and refuses a key the Workspace already holds in any case.
Every other command reads its Project from the Assignment, Comment, Artifact,
or Track its arguments already name, and refuses a `project` beside it as an
unknown key.

`dissolve-track` requires `release: true` whenever the Track holds any
Assignment, including open iceboxed work; without it the command answers a
`409` refusal. Release makes every member trackless, preserving its state,
Dependencies, and history. An empty Track needs no release confirmation.
The CLI expresses the same intent with `dissolve-track --release`.
The browser includes `confirmed_iceboxed`, the list of Assignment IDs shown in
its confirmation. With that argument, release succeeds only if those are still
exactly the Track's members and every one is open and iceboxed; a mismatch
answers `409` and changes nothing.

An Artifact's `material` is a string of text, or a held file offered as
`{"file": <digest>, "name": ..., "media_type": ...}`. A file is held first, by
`POST /api/material` with its bytes as the body, which answers the `file`
digest and `size` to offer; a body over 10 MB answers `413` before it is read,
and holding the same bytes again changes nothing. `GET
/api/artifacts/<id>/material` retrieves an Artifact's held file as a download
under its recorded name and media type; it is never served as a page.

Revisable fields follow presence: an absent key leaves the field alone, and
a present key — `null` included, for clearable fields — revises to that
value. A performed command answers `200` with the changed record (`begin`
and `continue` answer the continuation record); a domain refusal answers
`409` with the facts in conflict; transport failures are structured JSON
with `400`, `404`, `405`, `411`, `413`, `415`, or `503`. Each command runs in one
transaction on its own protected connection, exactly as a CLI invocation
would.

Every performed command also answers `reversal`: the one command that would
restore what the change altered, or `null` when no single operation would.
Removing a requirement is reversed by requiring again, a revision — of an
Assignment's framing, a Track, a Comment, an Artifact, the Project — by the
previous values of the fields it changed, parking one inactive Assignment
by thawing it, abandoning or delivering one inactive Assignment by reopening
it. Iceboxing or abandoning active work ends its pursuit, which thawing or
reopening does not resume, and delivering iceboxed work thaws it, which
reopening does not undo, so those answer `null`; so do abandoning several
Assignments at once, reopening (delivering again would
resolve the work at a new moment), requiring or unrequiring several
Dependencies at once, dissolving a Track (a recreated one is another Track),
creating a Project (nothing removes one), and anything recorded rather than
altered. A renderer offers to take a change back exactly when the answer
names how.

A response can be lost after its command has already performed, leaving a
client unable to tell whether sending it again would repeat the work. An
`Idempotency-Key` settles that: a retry bearing the same key and body is
answered with the recorded response rather than performed a second time. It
matters most for `frame`, `comment`, and `artifact`, where a blind retry would
mint a second Assignment, Comment, or Artifact, and for the `project` command,
where it would be refused because the first attempt already created the
Project; the browser sends every command under one.
Recordings live in server memory, bounded to the most recent 1024 keyed
commands of one serving lifetime — far wider than any transport retry — and
beyond that bound a retried key performs afresh.

The two compound commands answer with every record they touched, so a
renderer can redraw the change without reading the graph again. `join`
answers `assignment` and the `dependency` it added; `insert` answers
`assignment`, the `removed` edge, and the two `added` edges that replaced
it, in that order. Either is performed whole or not at all: a refusal leaves
the membership and every edge exactly as they stood.

These two want the key even though neither can duplicate anything. A
repeated `insert` answers `409` — the Dependency it would replace is already
gone, and the two it would add are already recorded — and a repeated `join`
answers the same way, its Assignment having already joined the Track. Those
are ordinary conflicting facts, indistinguishable from a refusal of a request
that never landed, so without a key the only way to learn which happened is
to read the graph back.

A browser page may read the boundary only from the very origin its request
is addressed to — the same host and port — which is where the instance
serves its own pages. A page from anywhere else is refused `403` outright
rather than merely denied a cross-origin grant, so it cannot make the server
spend an embedding pass on an answer it would never be allowed to read. No
cross-origin grant is made to anyone, since every page the boundary answers
is its own. Commands are closed to pages from any other origin for a second
reason: they require the `application/json` media type, which a page cannot
send across origins without a preflight this boundary does not answer. A
development server proxying `/api` presents the boundary's own origin in the
page's place, so no preflight arises there either. A request addressed to a
foreign host — the mark of a DNS-rebinding page — is refused whatever its
origin.

### Beyond this machine

On a tailnet or a private network, bind every interface and name the
hostnames the instance is reached by:

```console
$ megillah serve --host 0.0.0.0 --allowed-host megillah --allowed-host megillah.example.ts.net
```

Plain HTTP carries the record there. A tailnet encrypts it between machines;
an ordinary local network does not, and anything on it can read and write
the Workspace — a guarded instance's too, since its shared secret crosses
that network as readably as the record.

Anywhere else, put a reverse proxy in front of the instance to serve HTTPS,
and leave the instance bound to loopback behind it. Megillah serves no TLS of
its own. The allowed host is the name the proxy serves:

```console
$ megillah serve --allowed-host megillah.example.com
```

The proxy must pass each request's `Host` header through unchanged, port
included. The boundary admits a browser page only from the very origin its
request is addressed to, and it reads that address from `Host`, not from any
`X-Forwarded-*` header. A proxy that replaces `Host` with the listener's own
address leaves every page looking foreign: the browser shows the Workspace,
but its commands are refused `403`, with a detail naming the origin the page
came from and the host the request reached.

Caddy passes `Host` through by default and obtains the certificate itself:

```caddyfile
megillah.example.com {
	reverse_proxy 127.0.0.1:8642
}
```

nginx replaces `Host` unless told otherwise. `$http_host` keeps the port a
browser names at a nonstandard port, which `$host` drops:

```nginx
server {
    listen 443 ssl;
    server_name megillah.example.com;
    ssl_certificate     /etc/letsencrypt/live/megillah.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/megillah.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8642;
        proxy_set_header Host $http_host;
    }
}
```

Behind a proxy, the startup check reaches the instance at loopback, and
reports the public name at the listener's own port unreached at the
connection stage: that name resolves to where the proxy listens, and the
listener accepts connections at loopback alone. That line is expected here;
the public URL is checked by opening it.

An agent elsewhere reaches the instance through the proxy, at the boundary's
prefix:

```console
$ megillah mcp --listener-url https://megillah.example.com/api
```

When the proxy answers but the instance behind it does not, `megillah mcp`
says that something other than Megillah answered, naming the status the
proxy gave.

HTTPS keeps the record private in transit; it does not decide who may reach
it. Unless the instance is guarded by a shared secret, anything that can
reach the public name can read and write the Workspace.

### Guarding the boundary with a shared secret

An instance reached over a network can require a shared secret. Set
`MEGILLAH_SHARED_SECRET` in the environment `megillah serve` starts in, and
every request for the record or a command, under `/api`, must present it;
anything else is refused `401` with an `unauthorized` failure, before its
route is even looked up. The browser's page and assets are answered without
it: they hold no record, and the browser has to load them to ask.

```console
$ export MEGILLAH_SHARED_SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
$ megillah serve --host 0.0.0.0 --allowed-host megillah.example.ts.net
Guarded: this HTTP boundary answers only requests presenting its shared secret. Whoever holds the secret can read and write the Workspace record; it names no one, and it crosses the network readable unless HTTPS or a private network such as a tailnet carries it.
```

The secret is read from the environment alone, never an option, so it
appears in no process listing or shell history; surrounding whitespace, such
as the newline a secret file ends in, is not part of it. A variable set to
nothing is refused rather than read as no secret, since that usually means
the secret meant for it went missing. Nothing limits how often a secret may
be guessed, so make it long and random, as above.

A caller that is not a browser presents it as a bearer credential:

```console
$ curl -H "Authorization: Bearer $MEGILLAH_SHARED_SECRET" http://127.0.0.1:8642/api/projects
```

The browser asks for it once, when the instance first refuses one of its
requests, and holds that request and any other until the instance accepts
it; nothing typed into the page is lost meanwhile. The page presents the
secret to `POST /api/session`, and the instance answers with a session
cookie derived from the secret rather than holding it: sent only to
`/api`, never readable by a script on the page, never sent with a request
another site started, and marked `Secure` when the page was reached over
HTTPS. Every tab shares it until the browsing session ends. The cookie is
named for its secret, so instances on one host guarded by different secrets
keep their sessions apart even though a browser keeps cookies by host alone.
Change the secret and restart the instance, and each open page simply asks
again. Every answer a guarded instance gives is marked `Cache-Control:
no-store`, so no cache in between replays one to a caller without the
secret.

`megillah mcp` presents the secret it finds in its own environment, so give
each Client's server the same variable (`claude mcp add -e`,
`codex mcp add --env`, or an `env` entry in a JSON configuration). A server
given none, or the wrong one, says which when the instance refuses it.
`megillah init` run with the variable set says as much beside each Client's
configuration, and never prints the secret itself.

The secret protects the boundary; it is not identity. It names no one, one
secret is shared by everyone and everything that holds it, and whoever holds
it can read and write the whole Workspace, just as whatever reaches an
unguarded instance can. Megillah still does not represent who a person is.

The startup check presents the secret only once its probe has shown that an
address reaches this very instance, since a name may resolve to another
server and the probe travels over plain HTTP.

## Connecting an agent

`megillah mcp` serves the Workspace to MCP clients over standard input and
output. It opens no store: it reaches a listener over HTTP, so it can run on
a laptop against an instance serving somewhere else.

```console
$ megillah mcp --listener-url http://127.0.0.1:8642/api
```

`MEGILLAH_LISTENER_URL` stands in for the option; the default is where a
local instance answers, its loopback address and the boundary's prefix.
An instance [guarded by a shared secret](#guarding-the-boundary-with-a-shared-secret)
also needs that secret in the server's `MEGILLAH_SHARED_SECRET`.

The tools are the Workspace's own vocabulary: listing its Projects, reading a
Project and its Tracks, listing and searching Assignments, retrieving one
Assignment's continuation context, framing and reframing work, the whole
Assignment lifecycle, Dependencies, Comments, Artifacts, and Tracks. The server
decides nothing: each tool performs the same read or command an interface
would, and the listener remains the authority for what the record says and for
every refusal. A domain refusal comes back as an
ordinary result carrying the conflicting facts, so a collaborator reads them
and chooses again; only a listener that could not answer at all is an error.
Every contribution is attributed to the Client the connection negotiated, so
the record says which collaborator made it.

Search calls allow 30 seconds to read the listener's response; other reads,
connecting, and sending a request retain a five-second timeout. Read and write
timeouts are reported separately from an unreachable listener: work may still
be running after the caller stops waiting. If a command times out, its outcome is unknown;
retry it only with the same arguments and the idempotency key returned in the
timeout result, so an already-applied command is not repeated.

A collaborator sends a file as an Artifact's material whole, base64 encoded,
and the server holds it before sending the command that names it.
`retrieve_artifact_material` returns a held file as an image, text, or bytes;
continuation context describes held files and names that tool rather than
carrying them. A collaborator with a shell on the Workspace's machine can use
`megillah artifact --file` instead of sending a large file through a call.

A tool acting in one Project as a whole names it, as the boundary does: every
read of a Project, framing, creating, revising, ordering, and dissolving Tracks,
revising the Project, and recording a Project Artifact take a required
`project` key, and `list_projects` names the keys the Workspace holds. A tool
naming an Assignment, Comment, or Artifact takes no key, since its identifier
already names the Project, and `create_project` names none but gives the key a
new Project will carry. The server never picks a Project for a
call — not even a Workspace's only one — so a call keeps meaning the same
thing when a second Project arrives.

Each answer carries the release the listener reported, and a server built for
another release refuses it rather than reading a document whose meaning may
have moved — so keep the two ends current together.

`megillah init` prints each Client's configuration for the instance it
describes. Each names this installation's `megillah` by its absolute path,
since a Client need not start servers with the shell's `PATH` and Claude
Desktop, launched outside any shell, does not; and each names that instance's
listener outright, so a `MEGILLAH_LISTENER_URL` in the Client's environment
cannot point it elsewhere. Claude Code and Codex add a server from the command
line, Claude Code at user scope so that every project reaches the instance:

```console
$ claude mcp add --scope user megillah -- /home/meg/.local/bin/megillah mcp --listener-url http://127.0.0.1:8642/api
$ codex mcp add megillah -- /home/meg/.local/bin/megillah mcp --listener-url http://127.0.0.1:8642/api
```

Claude Desktop reads `claude_desktop_config.json`, opened from Settings >
Developer > Edit Config, and takes a new server when restarted:

```json
{
  "mcpServers": {
    "megillah": {
      "command": "/home/meg/.local/bin/megillah",
      "args": ["mcp", "--listener-url", "http://127.0.0.1:8642/api"]
    }
  }
}
```

A project's configuration can instead name `megillah` alone, relying on the
`PATH` of whoever opens it. Claude Code reads a project `.mcp.json`:

```json
{
  "mcpServers": {
    "megillah": {
      "command": "megillah",
      "args": ["mcp"],
      "env": { "MEGILLAH_LISTENER_URL": "http://127.0.0.1:8642/api" }
    }
  }
}
```

Codex reads the same configuration from `~/.codex/config.toml`:

```toml
[mcp_servers.megillah]
command = "megillah"
args = ["mcp"]
env = { MEGILLAH_LISTENER_URL = "http://127.0.0.1:8642/api" }
```

## Attribution and provenance

Operations record the contributing Client, read from options or the
environment:

| Variable | Meaning |
| --- | --- |
| `MEGILLAH_STORE` | Path to the Workspace store |
| `MEGILLAH_CLIENT` | The Client recorded as provenance |
| `MEGILLAH_PROJECT` | The Project a command acting in one Project as a whole acts in |
| `MEGILLAH_HOST` | Interface `megillah serve` binds |
| `MEGILLAH_PORT` | Port `megillah serve` listens on |
| `MEGILLAH_ALLOWED_HOSTS` | Hostnames the instance answers to, comma separated |
| `MEGILLAH_LISTENER_URL` | Where `megillah mcp` reaches the boundary |
| `MEGILLAH_SHARED_SECRET` | The secret guarding the boundary, which `megillah mcp` presents |

`megillah mcp` takes no session Client. One server serves many conversations
across many Assignments, so each contribution is attributed to the Client its
connection negotiated. Nor does it take `MEGILLAH_PROJECT`: a tool acting in
one Project as a whole names it. Starting it with that configured says so
rather than quietly ignoring it.

## Versioning

Megillah's release is derived, not written down. It is read from the commit a
checkout is on: the UTC day that commit was made, and where it falls among
that day's commits — `2026.9.10` for the first, `2026.9.10.1` for the next,
and so on. The build stamps it into the distribution's metadata, and installed
processes report that.

Every merge adds a commit and changes the release, without a manual version
edit. An installed distribution keeps the release stamped at build time,
even when its source checkout advances. Reinstalling updates that metadata.

Building needs the whole history. A shallow checkout holds only the commits it
was given, so it would count fewer of the day's commits than exist and derive
a release another commit already carries; the build refuses that rather than
answer it. Continuous integration must check out with full depth, and a source
archive carries the release already stamped into it, so building from one
needs no history at all.

`uv run python -m megillah.versioning`, run in a checkout, prints the release
that checkout derives.

### Publishing a release

Every merge to `main` whose Verify run passes is published to PyPI under its
release. Nothing is tagged or chosen by hand. Verify builds the source archive
and wheel, installs the wheel alone, and serves the browser from it. Once every
job has passed on `main`, it uploads that same wheel. The source archive is
built and checked but not published, which keeps each release small.

Releases are unique along `main`'s first-parent line: each commit there
descends from every earlier one, so a later commit made the same day always
counts more of that day's commits. Every commit on that line gets its own
Verify run, and runs on `main` are never cancelled, so no merge is skipped. A
merge whose run fails is not published, and the next one that passes is.

The upload uses PyPI's trusted publishing: PyPI accepts a job running in this
repository's `pypi` environment by its GitHub identity, so no credential is
stored. That environment admits only `main`, so pull requests and other
branches never publish. A published release cannot be replaced, only yanked.

This release identifies artifacts built from this repository. It is not the
Workspace schema version and not an outside-process protocol version: the MCP
protocol revisions the server speaks are negotiated with each client and move
on their own schedule.

## The browser

The planning browser lives in `browser/`: a Vite and React application over
the boundary above, owning interaction and visual projection and nothing
else. It reads the planning projections and the continuation record, and it
sends the command envelope; what a card may become, which
operations apply to an Assignment, and what takes a change back are all read
from the boundary rather than decided on the page. Its surfaces:

- `/`, where the browser opens, shows the Workspace's arrival: its Projects
  side by side, in the order they were created. Each shows its description,
  its active work grouped by Track (a Track opens to name its Assignments,
  blocked work marked, and quiet work marked), every Assignment that can
  begin, and how much open work waits on other work; each Assignment leads to
  its card in Placement. New Project asks for its name first and suggests an
  editable key: the first three characters of a single word, or the initials
  of several words, in uppercase ASCII letters and digits. The suggestion
  follows the name until the key is edited; a name without an ASCII abbreviation
  needs a manually entered key. Creation takes that key, name, and optional
  description, and the new Project joins the page; the listener
  alone decides whether a key can be used, and its refusal is explained in
  the dialog. Each Project's views live under `/projects/<key>/`, and an
  address naming no Project leads here. The Project's name in every view's
  header opens a menu of the Projects with their active and available
  counts: choosing one opens the same view there, without the focus, and All
  Projects returns to `/`. Moving between them replaces the page rather than
  reloading it.
- `/projects/<key>/sequencing`, where a Project opens, shows open work in
  the order it must proceed: each Track a row spanning from its first work
  to its last, each Assignment in the column its longest chain of prerequisites puts it,
  read along an axis from prerequisites to dependents, with abandoned
  prerequisites marked unsatisfiable. Every Dependency is a connector turning
  at right angles through the gaps between cards, in a lane of its own where
  gaps are shared. Nothing is placed by hand there: a card opens its record,
  and carrying one, by pointer or with Space and the arrow keys, to another
  card's side, a Dependency's handle, or another Track's row proposes the
  change carrying it there in Placement would, lighting the destinations the
  listener accepts, explaining a refusing side, and confirming a plural or
  cross-Track result as Placement does. A trackless card is carried on its
  own; released beside work in a Track it is offered joining that Track or
  staying trackless, and on a Dependency between two Tracks it is placed in
  the one chosen. Released anywhere else it changes nothing, and while it is
  carried the notice says that positions follow Dependencies. A card's menu
  shows it in Placement and offers, as Placement's does, Icebox, Remove
  Dependencies and icebox, Remove all Dependencies and Abandon where the
  listener accepts them. A carry, a menu and a change in flight exclude one
  another, and automatic refresh waits for them. After a change the order is
  recomputed, the card glides to where it now stands and pulses, or the
  notice says it has left Sequencing, with the change's reversal offered. A
  minimap framed on the work keeps the whole order reachable at card size,
  and a fixed column beside the drawing names each row. The names follow their
  rows vertically while panning, stay readable at every zoom level, and accept
  a carried card just as their Track's band does. Scrolling may carry the view
  past any edge of the work, so its first columns can be brought to the middle
  of a wide screen, but stops
  while a strip of the drawing is still in view, clear of the minimap; a move
  that leaves no card in view outside the minimap glides back to the nearest
  one.
  The sidebar creates, renames, and dissolves Tracks. **New Track**, or the
  finder's offer for a name not already held, opens the creation dialog;
  searching alone creates nothing. A new empty Track appears in the finder.
  Rename is a separate control beside each Track, including hidden ones.
  Dissolution follows Placement's offers: empty Tracks dissolve immediately,
  and Tracks holding only open, iceboxed Assignments ask before releasing the
  named members trackless, still iceboxed. A concurrent membership or state
  change refuses that confirmation without changing the record.
  A Track's name brings its row into view, a checkbox shows or hides
  its row (the row of trackless work too), and Show all, Show unresolved, and
  Hide all choose every row at once, Show unresolved keeping the rows that
  hold work in the order. Hiding a row moves no other work, and the sidebar
  names each Dependency reaching into a hidden row. Show resolved Assignments
  (`/projects/<key>/sequencing?history`) reveals the rest of the resolved work as history
  in the columns before a boundary marking where the order begins, moving
  nothing in the order; going to a Track whose work is all resolved reveals
  it.
  **Focus objective** in a card's menu opens `/projects/<key>/sequencing/<id>`: that Assignment
  and its transitive prerequisites stand out while other work remains visible
  as context, in the same positions. The objective is named above the canvas,
  with **Clear focus** to restore ordinary emphasis. Focus survives opening
  and closing records, reloads, and browser navigation. Focus changes and
  clearing retain the history choice; hiding history clears a historical
  objective with an explanation. An objective link reveals its row if hidden.
  A successful edit in the record brings its card into view and pulses it
  after the record closes;
  if it leaves Sequencing, a notice explains its departure and its objective
  focus clears.
- `/projects/<key>/placement` is the graph-shaping board: one row per Track
  beside a tray of connected untracked structures; `/projects/<key>/tracks`
  still leads there. An address naming an Assignment rings its card, or the untracked
  structure holding it, with the pulse a changed card gets. Carrying a card or structure to a card edge, a Dependency midpoint, or Track
  whitespace previews the exact change the listener offered there and asks
  for confirmation when the result is
  plural or crosses Tracks. Pointer hover and keyboard navigation explain
  a refusing card side at that destination. Releasing over that side or the
  nearby area treated as that side (its targeting halo) changes nothing and
  keeps the reason visible, without falling through to placement in the row.
  Track whitespace outside card halos keeps its placement and arrangement
  behavior. Every carry has a keyboard equivalent.
  The board starts with visible open work: available, active, and blocked.
  Show resolved Assignments reveals delivered and abandoned cards within the
  shown Tracks and in trackless work, without changing which rows appear or
  returning iceboxed Assignments. Filtering preserves records and saved
  arrangements; boundary notices name hidden Dependency endpoints, including
  abandoned prerequisites that still block open work.
  Tidy releases saved positions and derives an orderly layout for the whole
  board, including work hidden by the current view. Sort Tracks by
  dependency keeps the Track order sequencing derives, and is unavailable
  while the Tracks already stand in it.
  The Track finder creates Tracks, navigates to a Track when its name is clicked,
  and shows or hides its row through a separate checkbox. Navigating reveals a
  hidden Track; choosing the same name again brings its row back into view.
  All Tracks remain findable, including fully resolved, empty, and manually
  hidden Tracks. Show all reveals every row, Show unresolved restores the rows
  with visible open work, and Hide all hides every row. These actions apply
  across the Project even when the finder contains a search, reset individual
  row choices, and leave Show resolved Assignments unchanged. Choosing a Track
  also leaves that checkbox unchanged; opening a specific resolved Assignment
  reveals both its card and its Track. A dissolvable Track has a dissolve control in
  the finder as well as on its row, usable even while the row is hidden. An
  empty Track dissolves immediately; one holding only open, iceboxed Assignments
  asks before releasing them trackless, still in the Icebox. Each Track shows a
  muted total followed by a badge containing the open count, with one tooltip
  explaining both; counts exclude the icebox and do not change when resolved
  cards are hidden. A Track's row notes how much of its open work is in the
  icebox; resolved work parked there is left to the Icebox scope. Newly
  created Tracks appear immediately. When the resolved-work filter excludes
  members of a connected body, reveal those members and their Tracks before
  carrying the whole body, and before iceboxing it when they include work it
  would park. Iceboxing a body parks its undelivered work and leaves its
  delivered work in view.
  Whole-body actions retain their full membership across Tracks the User
  deliberately hides. A Track's row renames, reorders, folds, and dissolves it
  in place. The + beside its title controls opens Create Assignment with that
  Track selected; the Track can still be changed in the dialog.
- `/projects/<key>/assignments/{available,active,blocked,unresolved,all,icebox}`
  are the curation scopes with their measures. Outside the Icebox the selection is
  iceboxed as one atomic command; in the Icebox the selection is abandoned
  as one atomic command, and one selected Assignment at a time is thawed. A
  single selected Assignment is offered only what applies to it, and an action
  withheld from it stays visible but disabled, its tooltip giving the
  listener's reason. Several selected Assignments are offered the icebox
  whole, for the listener to accept or refuse together; in the Icebox they
  are offered abandonment when the listener offers it to each, and otherwise
  the tooltip gives its reason for each it is withheld from, while Thaw says
  it takes one Assignment at a time.
  Icebox has its own link in the browser header, opening the Icebox scope
  and marking Icebox as the current destination.
- `/projects/<key>/timeline` combines resolution history with the unresolved
  dependency structure and supports objective focus and Track filtering. It
  is absent from primary navigation but remains reachable by URL and
  Assignment-row shortcuts.
- Opening an Assignment exposes its continuation record and sends individual
  commands for framing, relationships, lifecycle, Comments, and Artifacts.
  Comments and Artifacts appear as separate feeds in recorded order. Artifacts
  start compact, with long descriptions and stored material expanded on demand;
  locations and revision actions remain reachable. Each Artifact opens
  independently and keeps its reading state through refresh, promotion, and
  revision editing while the Assignment stays open. Promoted and selected
  Project Artifacts use the same presentation.
  An Artifact takes a file of up to 10 MB as its material in place of text,
  downloads it, and replaces it in a revision; a larger file is refused where
  it is chosen.
  Actions puts Icebox or Thaw first, followed by Begin or Set aside,
  Deliver and Abandon, or Reopen as the record permits. Each action explains
  its effect. Continue is omitted from the open record, which already shows
  the continuation context and refreshes automatically.
  Actions keeps Deliver visible for blocked work and Icebox for visible work
  that is not delivered.
  When the listener withholds either, it is disabled, and hovering or
  focusing it shows the listener's reason: the undelivered prerequisites, or
  the visible open work that prevents iceboxing it alone.

Tracks share distinct colors across Placement, Sequencing, and the timeline.
Renaming, reordering, hiding, or revealing a Track keeps its color; newly
created Tracks receive unused colors. Colors are derived from the Project's
full Track roster, so dissolving a Track may reassign colors. Trackless work
stays neutral. In Sequencing, within-Track Dependencies are grey and crossings
use their source Track's color (the destination's when the source is trackless);
unsatisfiable Dependencies remain red and dashed.

The Assignment table can combine several Tracks or states: choices within
one filter match any selected value, while separate filters narrow each
other within the current scope. Applied filters can be removed individually
or cleared together. Filtering a selected Assignment out also deselects it.
Switching scopes keeps the previous scope's controls and rows together until
the new scope is read successfully. If that read fails, the previous scope
remains visible with the refresh error; Refresh retries the requested scope.

The table remembers its sort columns, directions, and precedence in this
browser for each Project, across all Assignment scopes including the Icebox.
The choice survives leaving the view, reloading, and reopening the browser;
clearing the sort is remembered too. Without a saved preference, the table
starts with Downstream descending. If browser storage is unavailable, sorting
still works while the table remains open.

Every planning view has a **Search records** bar. Type an ID, title, or phrase
and press Enter or choose Search to search the current Project's full record,
including resolved and iceboxed work and work hidden by the view's filters.
Results retain the search engine's order and show state and matching source
excerpts. Choose one by clicking or with the arrow keys and Enter to open its
Assignment record over the current view. Closing the record returns focus to
search and keeps the view, filters, scroll position, and camera. Escape dismisses
the results; Clear removes the query and results.

Prerequisite and dependent pickers find IDs and titles as you type; `10`
puts `MEG-10` ahead of partial ID matches. **Search full records** also finds
durable context such as Comments and Artifacts, with matching excerpts.
Only relationships the listener offers can be selected. Pickers include eligible
matches from up to 50 ranked search results; ID/title lookup remains usable
when that search is unavailable.

Every performed command is followed by an authoritative projection refresh;
the browser never reproduces a domain decision optimistically. While visible,
the browser also refreshes every three seconds after the previous read finishes,
so contributions through other Clients appear without a manual reload. Hidden
pages pause these reads and catch up when shown again; focus and network
recovery also prompt a refresh. Unchanged revisions preserve the current scene,
and automatic refresh waits for board gestures and dialogs to finish. Assignment
drafts survive refreshed records, and failed reads retain the last successful
view while subsequent reads retry.

The browser is built into the distribution alongside the listener that serves
it. Installing that distribution supplies both, so the browser needs no
runtime version handshake with the listener.

```console
$ cd browser
$ npm ci
$ npm run build                       # into src/megillah/web/browser, for the listener to serve
$ npm run dev                         # a development server proxying /api to the listener
$ npm run typecheck && npm run lint
$ npm test                            # fast unit suites
$ npm run test:e2e -- inspector        # workflows for the affected surface
```

Building a wheel builds the browser too, so this is only for working on it;
an installed Megillah already carries one.

The development server proxies `/api` through to a listener on loopback,
`MEGILLAH_API_PORT` choosing the listener's port and `MEGILLAH_DEV_PORT` its
own. Since the listener admits a page only from the origin a request is
addressed to, the proxy presents the listener's own origin in place of the
development server's — a trusted reverse proxy on the same machine, saying so.
It vouches for exactly the origins it serves pages at, its own port on
loopback or on a hostname it was told to answer to, and passes anything else
through for the listener to refuse: a page on another port of this machine is
no more entitled through the proxy than at the listener itself. It answers to
loopback alone unless `MEGILLAH_BROWSER_HOSTS` names more.

The browser is tested in three layers. Unit suites state the page's
decisions over plain values — when it reads and what it adopts, which search
answer wins, where a camera goes, what command a form sends — and run in
seconds. Browser workflows drive the built page, served from disk, against
answers recorded from the listener: `tests/boundary/browser_answers.py`
performs each scenario in `browser/tests/recorded` through the real HTTP
boundary and records every document the page reads, its `--check` fails when
a recording no longer matches what the listener serves, and the page's own
document schemas parse every recording. No listener, store, or model is started, and
the page is refreshed through its own wake events or a virtual clock, so no
workflow's verdict depends on elapsed time. Each workflow checks one way the
page is wired to its decisions or its boundary: it loads the page once at the
state it needs, performs the least interaction that exercises the wiring, and
observes that one result, so it takes a second or two. The decisions themselves,
and their combinations, belong to the unit suites; a slow workflow is carrying
code that is not its subject, or its subject is slow. The bundle is rebuilt
before the run when it is stale. Rendered layout, and what only the real
listener, store, and search model can show, are checked on demand by agents
when a change calls for them, as the `validate-visual-change` and
`exercise-live-browser` skills describe.

Choose local checks using the [testing guide](docs/testing.md). `npm test`
runs only unit suites; Playwright workflows are selected explicitly by file
or test name. CI runs every workflow, and `npm run test:all` runs both browser
test layers locally when a complete run is needed.

## License

Megillah is free software under the GNU Affero General Public License,
version 3 or later. See [LICENSE](LICENSE). Anyone may run, study, change,
and share it; anyone who offers a modified Megillah to others over a network
must offer them its source under the same terms.

## Development

```console
$ uv sync --locked --extra dev
$ uv run --no-sync ruff check .
$ uv run --no-sync ruff format --check .
$ uv run --no-sync mypy
$ uv run --no-sync pytest
```

The browser's own checks run from `browser/`, as above. The Python suite
does not need the bundle built: the tests of serving it use a stand-in
bundle of a few files, and the command-line tests that need a real bundle
are skipped until one is built. Search integration tests also skip locally
until `megillah init` or `megillah serve` has acquired the pinned models.

The [Verify workflow](.github/workflows/verify.yml) runs on pull requests and
pushes to `main`, with separate Python and browser checks. It installs from
`uv.lock` and `browser/package-lock.json`, prepares the pinned search models
for the Python suite's search coverage, and runs the browser's typecheck, lint,
unit suites, build, and workflows against recorded answers. The browser check
also runs the Python CLI serving tests with the bundle present, and checks
that every recording still matches what the listener serves. Failed browser
workflows retain their report and traces as `browser-test-results` for seven
days. Whether these checks are required for merging is configured
separately through branch protection.

The suites under `tests/` are placed by the level of abstraction they
exercise: `unit/` states promises over values and pure interactions,
`application/` exercises operations against a real store, `boundary/` drives
the CLI, the served Workspace, and the MCP server in process as black boxes,
and `composition/` proves the real sockets, processes, builds, and Git once
each. `tests/scenarios/` enacts one story per module and shows what the record
can answer afterward. While developing, `uv run --no-sync pytest tests/unit`
answers in seconds; add the level that owns what is changing. The
[testing guide](docs/testing.md) governs local check selection and reuse of
passing results through review and publication. Wall-clock budgets are marked
`assay` and run only on request (`uv run --no-sync pytest -m assay`).

When writing or reviewing Python or browser tests, follow the
[test-naming convention](docs/test-names.md).
