Help
This plan has Records, Cycles, Graph and Timeline. The guide below describes every view openproj has; the others are turned off here by views in config/defaults.yaml, and issues by kinds.
Contents
Quickstart
A plan is a git repository of markdown files, one per record, and openproj is the tool that serves
and edits it. There is no database behind the pages — every one of them is drawn from those files,
and every save is a commit on the plan repository with your name on it. Sign in with GitHub: a
server started with --auth github was started with an org, and being a member of that org is
what lets you write. Anybody can read.
If there is no plan yet, openproj init <dir> starts one — the four config files with nothing
invented in them, a README, a .gitignore, and one commit under your git identity, so the next
command is openproj new. The README's A plan of your own has the flags and what it asks at a
terminal.
Records is the landing page: every record in the plan, one line each, newest edit first, with the search box above it. The tabs are the plan seen several ways — Table is where most people live, Graph is the dependency diagram, Timeline the Gantt, Cycles one page per cycle with what was bet in it and who has room, People who is on what. Clicking any record opens its own page, which is where you edit it. Filters live in the URL, so the view in front of you is a link, and there are no saved views to manage.
A new plan is empty, on purpose: init writes an empty cycle table and a roster of at most one,
because a calendar copied from somewhere else is somebody else's calendar forever. Start a cycle
from Cycles, then write the first pitch.
The three things
A pitch is a bet: a problem, a solution shaped to fit an appetite, and the argument for spending the time on it. It is one file — the fields at the top, the shaping document underneath — so the thing being bet on and the reasoning never drift apart.
A task is a piece of a pitch, with its own size and its own people, taking its cycle from the pitch it belongs to. A task with no parent is a chore nobody pitched, and is bettable in its own right.
A cycle is the block of weeks the team bets in. It stores two dates, both of them meetings:
starts_on is the betting table and the first day of build, reviews_on is the review. Where build
ends, how many working weeks that is once the holidays are out, and where cool-down ends are all
derived from those two and from each person's availability.
Three more you will meet. A project groups pitches into a milestone: no size, never bet, its span the rollup of what is inside it. An issue is somewhere to put something existing that is broken — most issues will never be worked on, which is exactly what they are for. A note is an idea before anybody knows what it is: no appetite, no owner, no size, and on no view of the plan. Promote turns either into a project, a pitch or a task in one commit; the source stays, and points at what it became.
Appetite is person-weeks
A size is the work one person would need. It is not elapsed time, and that is the distinction people get wrong first: six person-weeks is six weeks for one person at full availability, three for two people, twelve for one person who is half on something else. The cycle's roster says what fraction each person has.
A pitch that has tasks takes its dates and its load from them, so the pitch's own appetite stays
the bet, as the room agreed it. What the bet buys is a number of calendar weeks — eight
person-weeks with two people on it is four weeks — and that is the box the tasks have to fit in.
When the tasks as they are actually staffed need longer, the page says so and openproj check
warns. Who is on them is what decides it: a four-week task and a half-week task are four and a half
weeks if one person holds both and four if they run side by side. Nothing refuses the save, because
cutting scope, re-betting, and putting another person on it are all decisions for a person.
Two dates, and both of them happened
The only dates anybody types are the two ends of work that is real. start_date is the day the work
began: on something that has not begun it is a day named in advance and has to still be ahead — a
start date already in the past is refused before in_progress — while from in_progress on a past
one is expected, because "I started this on Monday and it is now Wednesday" is the ordinary case and
the only thing the field can mean once work is under way. end_date is the day it finished, and
marking something Done asks for it with today already filled in.
No forecast is ever stored. The end a plan shows for work still in flight is derived, and it has to be: it moves when a blocker slips, when somebody fills up, when a cycle's review date is set, and at midnight. Written into the file it would be the one number nothing else agreed with. A recorded end is the opposite — it is what happened, it never moves again, and it is the only reason the plan can say afterwards whether a bet landed inside its cycle.
Everything else is derived: starts, ends, the critical path, when a person frees up, whether something runs past its cycle — all of it from the sizes, the dependencies and the roster. So when a date is wrong, do not go looking for the date: change a size, change what the thing depends on, or change an availability.
A date wildly outside the cycles the plan has dated is refused when you type it and reported by
openproj check if it got in another way. A year typed wrong parses perfectly and then belongs to no
cycle at all, so the record silently stops counting towards anybody's capacity — which is the one
kind of wrong date that leaves nothing to chase.
Only depends_on is stored, and on the thing that is waiting; what a record blocks is derived by
reversing it, so the two can never contradict each other. Any kind may depend on any kind — a task
can wait on a whole pitch — and a dependency written on a pitch is what every task inside it waits
for.
Finding one
Type into the box above any view. It searches the fields a record is known by — id, title, tags, PR references and the people on it — and never the shaping document underneath: a nine-hundred-word pitch would make every long word in the plan a match for something.
Capitals do not count, and neither do the separators — anywhere, in a bare word and in a
field:value alike. A record called some_cool_title is found by some cool, Some Cool,
some-cool, somecool and title:some.cool; tag:bed-heat and tag:bedheat are the same
question, and so are id:pitch-0a0001 and id:pitch0a0001.
A record is also found by the letters of its name when they sit close together: smcl finds
some_cool_title, and prtng finds "Porting throughflow". Four letters at least, and it reads only
the title and the id that way — three letters read loosely can pull back half a plan, and a tag, a login
or a PR number is something you copy off a screen rather than half-remember. A search made only of
punctuation, or of so much punctuation that fewer than three letters are left of it — #, C++,
I/O — matches nothing rather than matching whatever is left of it.
field:value narrows to one field — owner:jackdawrie, tag:gpu, status:ready, cycle:37. A
field is still matched whole, so cycle:3 answers nothing at all rather than standing for every
cycle whose number starts with a 3, and status:done is not a way of asking for every status with
"done" in it; title:, pr: and the three below are the ones that look inside.
product:, project: and pitch: ask about an ancestor rather than about the record in front of
you, so each returns the record named and everything filed under it, however deep:
project:warm_bubble is the project, its pitches and their tasks, and pitch:reproducibility is
that bet and the work under it. Either name works — the title, as the menu above the box draws it,
or the id it submits. A bare word never does this: warm_bubble on its own finds the project
record and nothing else, which is how you ask for the one rather than the hundred.
Two terms beside each other mean both; or, not and brackets are there when you want them. One
thing to know about not: it takes away whatever its word finds, and its word finds a little more
than the letters you
typed, so not smcl hides a little more too. A query the box cannot read matches nothing and says
why, under the box, while you type the rest of it.
Writing one
New record, then choose the kind. The body starts as that kind's template, with its guidance in comments that never render: Problem, Appetite, Solution, Rabbit holes, No-gos, For later. Those are the headings of a pitch note in a wiki, kept as the team it was tailored for already wrote them, so a pitch drafted in either place is the same document.
Write prose; nothing validates it. Three headings are read rather than judged:
## Progresson a task is its checklist, and so is## Solution— a pitch writes the work it is proposing there, and those boxes get ticked as it is done. Only those two sections are counted: a box under## Rabbit holes, or one quoted in the prose above the headings, is not work anybody is doing. A record with neither section reports no progress at all rather than zero. A pitch's progress is its tasks instead, weighted by their sizes — so4/7.5 wkmeans four weeks of a seven-and-a-half-week bet, not four ticks out of seven.## For lateron a pitch is scope cut to fit the appetite. It is the only record the plan keeps of a bet that was trimmed, and it was invisible until it had a name.
Equations are LaTeX between dollars: $\Delta t$ inline, $$ … $$ on its own line. They are drawn
by the browser as MathML — nothing is fetched, so they survive a static export and a printed deck.
A dollar with a space or a digit after it is a dollar ($5 and $7 is money), and an equation that
does not parse is left as the text you typed, marked, rather than swallowed.
The statuses
Six, in the order work moves through them. What each one additionally requires is checked when you
save and by openproj check.
| status | what it means | what it then requires |
|---|---|---|
thinking |
nobody has looked at this yet, and it is where a new record opens | nothing |
shaping |
an idea nobody has bet on | nothing — it has no owner and no size by definition |
ready |
shaped, and bettable | an owner, somebody assigned, a reviewer or review_waived, and a size |
in_progress |
being built | start_date, a size, and a reviewer who is not the owner |
done |
finished | at least one PR, and end_date — the day it finished |
shelved |
parked | nothing — parked work is not broken work |
An issue has four of them: no shaping, because a shaped issue is a pitch, and no thinking,
because an issue is something somebody reported. A note has thinking and dropped and nothing
between them.
A record that is missing something still saves and still loads; the page says what is wrong, beside the record it is wrong on. A rule only blocks records written after that rule existed — older ones warn instead.
Editing a record
Clicking a record opens its page, and that page is the editor. It lands on the reading view; the writing box and a live preview sit side by side, and the split between them is draggable. The fields at the top are the frontmatter and the box below is the shaping document — one form, one Save, one commit. Reset puts everything back to what was on the page when you arrived and leaves you in the editor.
The keymap button on the status strip turns vim keys on, and the editor keeps three things about
them: j and k walk the wrapped line rather than the file, y also puts what you yanked on the
system clipboard, and :w presses Save. Ace ships :w doing nothing at all, which is worse
than not having it.
The status control is the hill beside the title: drag the ball to the stop you want. Changing a record's kind is the chip beside it — a pitch that turns out to be a task does not have to be rewritten. Promote on an issue or a note writes the pitch, the task or the project it becomes, in the same commit that marks the source. Delete asks first.
The button beside the image button opens a drawing canvas; what you draw is saved as a PNG in the plan repository and referenced from the body, so a sketch on a pitch is versioned with the pitch. You can drop or paste a picture straight into the canvas and draw on top of it — a photograph is scaled down to 1440px on the way in, so one fits comfortably inside the 2 MB a drawing may weigh. Three or four full-size pictures in the same drawing will not, and the Save button says so and keeps your work on screen rather than losing it.
Several people can have the same document open and type in it at once. It still ends as one commit,
authored by whoever wrote the most of it, with a Co-authored-by: for everybody else. If somebody
committed to the same record while you were writing, the page says so in one line and keeps your
draft rather than throwing it away.
Editing it in git
Both directions are first class. The files are ordinary markdown, a save from the browser is one commit, and a commit you push by hand is picked up within a minute of somebody using the service. A save only rewrites the fields whose values changed, so comments, key order and list style survive it. From a clone, with no service running:
openproj check . # every rule, exits non-zero only on blockers
openproj schedule . # the derived dates, one line per record, with the reason
openproj render . out/ # the pages as static files
openproj render writes the whole plan as static files, which is the copy to keep when there is no
service left to serve it. To run the editable server against a plan of your own, point it at a bare
clone:
git clone --bare <your plan repository> plan.git
openproj serve --repo plan.git --auth dev
--auth dev skips sign-in and is for a local run only. --auth github is the other, and it refuses
to start without an org — --org, or OPENPROJ_ORG — because membership of that org is the whole
of the write gate and there is no default that could stand in for it. A deployment is described in
the plan repository, not here: deploy/RUNBOOK.md in the tool's repository is the walk-through.
To see the tool with no plan to point it at,
openproj demo serves a bundled corpus offline, in a temporary directory it builds for itself.
Writing a record from a terminal
openproj new is the other door into the plan, and the one to use when there is no browser — a
script, a CI job, an agent working in the codebase the plan is about.
openproj new issue . --title "Burner emissivity is hard-coded in two places" \
--tag burner --as jackdawrie --commit
It mints the id, files the record in its kind's directory, starts the body from that kind's shaping
template, and stamps the day and the schema version the repository is on. Then it holds the record to
every rule check holds it to, before anything reaches the disk: a blocker means nothing is
written at all, so there is never a bad file to rm your way out of. A warning is printed and the
record is written anyway, which is the case this exists for:
warning: issue-b71a56: prs: an issue is never scheduled, so its prs is not read
prs is a real field on the model, so nothing refuses it — it is simply never read on a record that
is never scheduled, and that is the kind of thing you cannot see by copying the record next door. The
six things new does not ask you for are the six that somebody copying gets wrong: the id, the
directory, the body template, the opening status, the date, and the schema version.
--set field=value writes any other field, repeatably, with the value read as YAML — --set person_weeks=1.5 is a number, --set review_waived=true is a boolean, and a field that holds a list
takes one --set per entry. --body-file replaces the template and - reads the body from stdin.
--json prints the id and the path, for a caller that is not a person. Without --commit it writes
the file and prints the git commands; with it, the next command is git push and nothing else.
Nothing has to be installed first, and since 0.43.0 that is as true of serve, render and demo
as of new: the wheel carries the pages' static files and the demo corpus, so none of them needs a
checkout of the tool.
uvx openproj new issue . --title "…" # from PyPI
uvx --from git+https://github.com/jcanton/openproj openproj new … # straight from this repository
🤖 Written by an agent on behalf of @jcanton
Shape Up, as this tool practises it
How this maps to a pitch note in a wiki
The tool was tailored for a team whose shaping lived in a wiki: a note per pitch with the fields in its header, a sheet per cycle with the available people and a task table, and one table across cycles — theirs was called Greenline — for what depends on what. The table below is the mapping; where a row is more than a rename, the reason is in the row.
| in the wiki | in openproj |
|---|---|
| A pitch note | a pitch record — frontmatter, and the shaping doc as the body |
Shaped by: @a and @b in the header |
owner — who shaped it and holds it. One name where the header takes two: a deliberate trade, made to stop owner, shaped-by, assignees and reviewers being four lists of people on one record |
Appetite (FTEs, weeks) |
person_weeks — the work one person would need; the people on it divide it |
The cycle sheet's Available people |
availability: in cycles/<n>.md, a fraction of the build weeks |
| The cycle sheet's task table | the betting table on /cycle/<n> |
The sheet's ## Goal, and what was said while betting |
the cycle record's body, editable on that page |
Support |
reviewers — the role includes support, and it makes somebody accountable |
The Greenline table's Depends on |
depends_on, with blocks derived from it |
The Greenline table's Shape doc link |
there is no link: the shaping doc is the record |
Where this departs from the book
Deliberately, and with the practice of the team it was tailored for as the reason:
- A size is person-weeks and staffing divides it, so the tool forecasts dates the book would not.
- Cycles are soft walls and the scheduler runs work past them rather than stopping, because the circuit breaker is a human decision made at the review meeting.
projectis a milestone layer the book does not have, because the Greenline table already tracks cross-cycle dependencies.- Progress is the body's own checklist rather than a hill chart, because a checklist is what the
team it was tailored for actually keeps. The hill on every record draws
status, not progress — nine of ten boxes ticked can honestly still be uphill, because the tenth is the one nobody knows how to do — so the tool draws both and converts neither into the other. The ball is the control that sets the status, and it only moves while the record is being edited, so a move costs the sentence that explains it.
Five things that team asked for at the tailoring pass were declined; AGENTS.md has them and the
reason for each. Everything here is paraphrase — Shape Up is free to read online, but the
reproduction terms are somebody's to check.
🤖 Written by an agent on behalf of @jcanton
The data model
One markdown file per record: YAML frontmatter, then the body. Git is the
database — every write is one commit through store.py against a bare repository, from the editor,
from /api/record, or from somebody with a terminal — so everything here is either a field in a file
or something derived from the files, and nothing derived is ever written back. architecture.md
has the pages these structures are drawn on; this file is the structures.
One type, six kinds
There is one record type. Record (model.py) carries what every kind shares — id, kind,
title, parent, status, owner, assignees, reviewers, review_waived, start_date,
end_date, priority, depends_on, cycle, tags, prs, body — and kind says which rung it is on. The
six rungs are subclasses that add fields. They are not six different things: one model, one parser,
one write path, one page.
| kind | id | files | filed under | in the plan | fields it adds |
|---|---|---|---|---|---|
product |
prod-_ |
products/ |
— | yes | none |
project |
proj-… |
projects/ |
product |
yes | none |
pitch |
pitch-… |
pitches/ |
project |
yes | person_weeks |
task |
task-… |
tasks/ |
pitch, project |
yes | person_weeks |
issue |
issue-… |
issues/ |
— | no | reported_by, opened_on, pitched_into |
note |
note-… |
notes/ |
— | no | written_by, written_on, became |
That table is not prose about the code. It is KINDS in model.py, and everything else is
derived from it: the directories the loader walks, the id pattern, the parent rules, the filter
menus, and the create form. A seventh kind is a row, not a search for the places project was
written down.
Each kind is one thing and says so:
-
A pitch is the unit of the bet: what the betting table offers, and the only kind whose body the shaping hints read. Its
owneris who shaped it and holds it — there is noshaped_byfield;assigneesbuild it,reviewersread the PR. -
A task is a piece of a pitch, with its own size and its own people, taking its cycle from the pitch. A task with no parent is a chore nobody pitched: bettable in its own right.
-
A project groups pitches. No size, no capacity, never bet; its span is the rollup.
-
A product is a codebase and a container for projects. Work in one waits on work in another, which is why one plan holds all of them: separate plans cannot express a cross-product dependency. Container and nothing else — no status, no PRs, no appetite, never scheduled.
-
An issue is something existing that is broken, before anybody has decided to fix it. No
shapingstatus, because a shaped issue is a pitch. -
A note is the second inbox, and one sentence pays for having two:
an issue is "we found something existing that is broken", a note is "we are thinking of creating something that does not exist and our ideas are confused".
A note is therefore not a pitch in
shaping: a pitch presupposes you know what you are shaping. Two written statuses,thinkinganddropped, pluspromoted, which is derived.
product ← project ← pitch ← task is enforced, not just documented: a parent of the wrong kind is a
blocker for anything written since the rule existed and a warning for everything older.
Status is a written field; state is what a record actually is. An issue derives in_progress and
done from what it was pitched into, and a note derives promoted from became — derived for the
reason blocks is: a copy stored beside the link goes stale the first time somebody closes the
pitch.
A plan directory is flat. products/, projects/, pitches/, tasks/, cycles/, issues/,
notes/ and people/ hold one file per record and nothing below them, because every reader takes an
identity off the filename: people/team/ann.md is a second ann. A file below a plan directory is
named on every page and by openproj check, with the move that fixes it.
Two populations: Index.records and Index.plan
build_index (index.py) turns the parsed files into one in-memory Index, holding the same
records twice under two names because two questions are asked of them. records is everything that
parsed, whatever its kind — the landing list, the detail lookup and the delete cascade read it,
because those must resolve an id that may name an issue or a note. plan is the kinds whose rung
says planned, and every PM surface reads that. A validator refuses a plan holding an unplanned
kind, so a consumer somebody forgets fails closed: it sees fewer records, never an issue on the
timeline.
Promotion
An inbox that cannot become work is a second inbox nobody empties. POST /api/promote is the one
door out of both:
flowchart LR
subgraph inbox["not in the plan"]
note["note"]
issue["issue"]
end
subgraph plan["the plan"]
project["project"]
pitch["pitch"]
task["task"]
end
note --> project
note --> pitch
note --> task
issue --> pitch
issue --> task
- The source survives — it is the only record of the thinking that led to the bet — and gains
became, orpitched_intoon an issue. - The new record says where it came from in its own document, in prose and not in a field.
- One commit, because it is one decision. As two, the second can fail after the first has landed and leave a pitch nothing points at.
- Title, tags and body cross, and nothing else. The new record is created in
shaping, the one status whose required-field gate is empty, so a promotion always validates without inventing an owner, a size or a cycle nobody agreed to.
Sizes, dependencies, requiredness
A size is person_weeks on a pitch and on a task, and the people on it — its assignees — divide
it, each at their own availability. There is no default. A record nobody has sized is not scheduled, weighs nothing in
its parent's progress and charges nobody's capacity, and every page that adds weeks up says how many
records it could not count — a number the tool invented is a number that arrives everywhere looking
like one somebody estimated. Shaping and thinking work is unsized by definition and stays that way;
ready and in_progress both demand a size. A pitch with tasks takes its dates and its capacity
from them, which makes its own appetite the bet: what the room agreed to spend, kept as written.
The comparison against it is calendar against calendar — the bet over the people on it is a number
of weeks, and the days its tasks occupy are another — so a bet that holds the work as effort can
still fail to hold it as time, when one person has to do two things in turn. Where the tasks do not
fit, the page says so and openproj check warns; nothing refuses the save, and neither a pitch nor
a cycle over its capacity is ever a CI failure.
Only depends_on is stored, on the dependent. Any kind may block any kind, and an edge written on a
pitch is inherited by everything inside it. The one forbidden direction is your own containment
chain: a dependency along it demands to be both before and after itself.
Requiredness is status-gated, lives in validate_all, and is enforced in the create form,
openproj check and the index gate. quickstart.md has the table of what each status requires.
Priority is one of very_high, high, medium, low, very_low — five rungs, because three left
the team writing High+ in the margin of its own table.
A reviewer is named when the bet is made, not when a PR appears: a bet nobody will review is a
bet that should not be made. review_waived is a deliberate act, for work with nothing to review,
and it is a facet and a count, so a team that waives everything sees itself doing it. A reviewer is
not a worker: review is never charged against capacity. Neither is an owner: the owner's job
is to see the work done, and they are booked, scheduled around and charged capacity only if they
are also among the assignees. A record with an owner and nobody assigned is work nobody is on.
Parse permissively, validate strictly, and grandfather. Every field is optional at the type level
and status and priority are plain str, so a hand-edited file with a missing field or a retired
word still loads and reports a problem instead of taking the index down; only kind is strict. Each
rule also records the schema_version that introduced it, and a record is blocked only by rules that
existed when it was created — without that, one new required field invalidates the whole corpus at
once.
The structures that are not records
Cycle — cycles/<n>.md, frontmatter and a body like a record, but not on the ladder: no id, no
kind, and it lives in Config.plans. It stores two dates, and both are meetings: starts_on is
the betting table and the first day of build, reviews_on is the review. Beside them sit an
availability fraction per person, a goal, and a body for what came up. Everything else — where
build ends, how many working weeks it holds with the holidays taken out, where cool-down ends —
is derived, and a date the tool had to assume is marked as assumed.
A record's cycle: records where a bet was made and is never re-stamped, so an overrun keeps
accusing. Everything under a pitch takes the pitch's, and a project therefore has no cycle at all.
Person — people/<login>.md, holding what one person chose for themselves. The login is the
filename and is not a field: nothing points at a person, so a second copy would only give the two
halves of the app something to disagree about.
Config — config/*.yaml: schema_version, nominal_availability, cooldown_weeks,
repositories, views, kinds, holidays, the cycle windows, and known_people, the roster.
Empty means the check is off; when it does name people, somebody who is not on it is a warning and
never a blocker. views is the views this plan has, in nav order after Records, and it is held
resolved — every name it could not use left out and reported — so nothing past reading the
config ever sees the list as written. kinds is which of the unplanned kinds, issue and note,
the plan has; a record of a kind that is off still loads, is listed and warns, because a list that
drops rows and looks normal is worse than one that says a row is wrong.
Problem and Unreadable — a Problem is keyed by record id, so every page hangs it on that
record's row. An Unreadable is keyed by a path: a file that will not parse has no record, which is
precisely what is wrong with it.
Architecture
The pages
index.html is Records, the landing page — every record in the plan, one
line each, newest edit first, with the search box above it. table.html is the
filterable, searchable table and the page PM work lives in. graph.html is the
dependency DAG, grouped by project and pitch. timeline.html is the derived
Gantt. cycles.html and people.html are the cycle records with their betting
tables, and who is on what and who is full — and on a cycle's roster each
person's row carries their scheduled end date beside their capacity bar, so a
green capacity bar can never sit next to a timeline running into November
without saying so. detail.html is one record on its own page — any of the six
kinds — and under the server it is also where a record is edited. help.html
is this documentation.
Issues and notes are records whose rung says planned=False: they are on
Records and on their own pages, and never in the plan views.
A plan says which views it has. There are eight — table, graph, timeline,
cycles, deck, people, issues and notes, the deck being one cycle's
review with no place in the nav — and views in config/defaults.yaml lists the
ones this plan has, in the order its nav shows them after Records. Without the key
a plan has every one of them; views: [] is Records alone. Records, every record
page and Help, in the footer, are always there whatever the list says: Records is
the landing and the home of Create, and every bar, node and cycle row links to a
record. deck needs cycles, and the Issues and Notes lists need their kind,
which kinds beside it switches: kinds: [note] is a plan with notes and no
issues, and the four planned kinds are always on. A view that is off is off
everywhere — gone from the nav, linked from nowhere, and its address answers 404
with a page naming the setting and the file, rather than a redirect that would
change the page under a bookmark with nothing saying why. openproj render
writes the same views and no others. A switch takes away a page and never a fact
the schedule is computed from: with Cycles off, the cycle windows and the rosters
still schedule the work, and no API is switched, because the plan is on GitHub and
a switch is focus rather than secrecy. A name that cannot be used — a typo, a
duplicate, an always-on name, a view whose dependency is off — is left out and
said, in the banner on every page and by openproj check.
The pages render from one in-memory index, share one filter model, and keep their state in the query string — so every view is a shareable URL, and there are no saved views to manage. The index is rebuilt whole from a commit and never incrementally — measured at ~50 ms over 300 records and ~250 ms over 2,000, which buys the deletion of the entire class of incremental-invalidation bugs. It is an immutable snapshot swapped in atomically, so a reader holds a reference and is never blocked by a write.
/deck/<n> is a route only: the deck for one cycle's review meeting — a title
slide, then one slide per piece of work bet into that cycle It takes a cycle
number, so it has no place in a static export; it is reached from that cycle's
own page.
Two repositories
The tool and the plan are separate repositories, and stay separate in production.
jcanton/openproj this repo — code, tests, and fixtures. No real plan data.
<your plan repository> the data — markdown records and config. No code.
Three reasons the split is load-bearing:
- A plan commit must not run the tool's CI. Someone changing a status should not queue a test suite, and a red suite should not block someone changing a status.
- The write credential must be structurally incapable of touching source. Scoped to one
repository with
contents: write, a leaked token costs you a revertable plan; scoped wider it is a supply-chain foothold in the codebase the plan is about. - Their histories have nothing to say to each other.
git logon the plan is a record of decisions; on the tool it is a record of code.
The seam is the --repo argument. The server holds a bare clone of the plan repo and knows nothing
else about it, so pointing a deployment at a different plan is a flag, not a fork.
git clone --bare https://github.com/<your plan repository>.git plan.git
uv run openproj serve --repo plan.git --auth dev
A plan repository starts with openproj init DIR: the four config files at the newest schema
version with nothing invented in them, a README.md, a .gitignore, and one commit under your
git identity — refused before anything is written if there is no identity or the directory is not
empty. At a terminal it asks for what the flags left out (--org, --remote, --as); anywhere
else it asks nothing. Asked, or given --deploy KEY=VALUE, it also writes deploy/openproj.env
into the plan, and that file is where a deployment is described: gcloud_deploy.sh <that file>
reads the cloud project, the region, the plan's remote, the org and the App's identifiers from it,
so the tool's own source names no plan, no org and no cloud project. deploy/example.env here is
the same file with every value blank. The two secrets are asked for at the prompt and go to Secret
Manager, never to disk.
For a look at the tool with nothing to point it at, openproj demo does the same against the
bundled seed/ corpus in a temporary directory — an invented roastery, so nobody's real plan
ships in the wheel. Since 0.43.0 the wheel carries static/ and seed/, so uvx openproj serve,
render and demo work without a checkout. --auth dev is for a local run only; a deployment
runs --auth github, which refuses to start without a signing secret, an OAuth client, and the org
whose membership decides who may write — --org, or OPENPROJ_ORG, with no default, because a
default would be one team's. deploy/RUNBOOK.md has the rest.
On Cloud Run the container clones the plan repo on boot and pushes on write, which is also why the
running service is close to stateless — the durable data is the git remote, not the disk. It is
also why one image serves any plan: which plan is OPENPROJ_REMOTE at boot, and a plan change
never builds an image.
Layout
src/openproj/model.py schemas, parse, round-trip serialise, validate_all
src/openproj/schedule.py the scheduler — a pure function, the product
src/openproj/index.py the snapshot every view renders from
src/openproj/query.py the search language, shared by the server and the browser
src/openproj/render/ the pages — a package behind a re-exporting facade
src/openproj/store.py the git write layer — bare repo, one writer, scoped CAS
src/openproj/pusher.py the deferred push: a commit lands locally, then goes out
src/openproj/coedit.py the co-editing rooms — one Y.Text per record, in memory
src/openproj/web.py the server: routes, auth, the write endpoints
src/openproj/auth.py sign-in: the OAuth dance, the session, who may write
src/openproj/github.py what the server asks GitHub — the App token, open PRs
src/openproj/vendor.py static/ and docs/ on disk, read once and inlined
src/openproj/bootstrap.py openproj init — the config files with nothing invented, one commit
src/openproj/cli.py init / check / new / render / serve / schedule / demo
src/openproj/themes.py the colour schemes — sixteen numbers a row, nothing else
seed/ the demo corpus
tests/fixtures/corpus/ the frozen golden corpus the scheduler goldens pin
static/ vendored, pinned JS — see static/VENDOR.md
docs/ this documentation, and what the app's Help page reads
deploy/ the container entrypoint, the runbook, the blank deployment form
gcloud_deploy.sh one deployment, from the env file a plan repository holds
store.py is a bare repository with no index to contend for, one writer behind an flock, and
compare-and-swap scoped to the path being written — so an edit to a different file retries invisibly
and only a genuine overlap is refused.
No npm, no build step, no CDN, and tests/test_render.py asserts no rendered page reaches the
network. Two vendored libraries are fetched rather than carried, both megabytes: the drawing editor
on the first press of the drawing button, and mermaid on a page that has a fence for it.
GET /static/<name> answers for an allowlist of exactly those two; static/VENDOR.md has the
arithmetic. A diagram in a document is a diagram where there is a server and its source where there
is not — openproj render writes files opened over file://, which have nothing to fetch from.
node is not needed to build or run it, and is needed to test it fully. A set of tests runs the
shipped page scripts against a minimal DOM; without node on PATH they skip, and a suite missing
them is green for the wrong reason.
Colour
Two controls in the corner, beside the identity. The light/dark switch is the polarity; the picker beside it is the palette — nine base16 families, each a light and a dark, plus the app's own colours, which are the absence of a choice rather than a family called "default".
A code fence that names its language is syntax-highlighted, on the server: Pygments produces the spans and the page carries a stylesheet, so highlighting costs no fetch, works in the static export, and adds nothing to a page with no fence on it. Its eight colours are derived from the scheme's own hues rather than taken from them — a palette written for a terminal puts most of those hues below readable on a near-white page, which was measured before it was believed.
A scheme is sixteen colours and the app draws with fifty-five, so the other thirty-nine are derived
once, for every scheme. themes.py is a table with a row per palette; adding a family is a row.
tests/test_themes.py measures the result twice — the palette in Python, and what the page paints
in Chrome, at AA for every chip and every fill of every family in both polarities.
Co-editing one document
Several people can type in one shaping document at once. coedit.py holds the rooms — one Y.Text
of the markdown body per record, and nothing else; the frontmatter stays on the form, where the
fields are typed and validate_all decides requiredness in one place.
A room is a way of arriving at a commit, not a replacement for one. Every room ends in exactly one
store.write against its own base — on Save, on the last participant leaving, or after twenty
seconds of quiet — so a person editing in git, in a second tab or through the API is still handled by
the same three-way merge, and a genuine overlap is still the same refusal. The author is whoever
inserted the most characters since the last commit, with a Co-authored-by: for everybody else.
Nothing is persisted: git holds the text. The room is in memory on one process, which
--max-instances 1 makes safe, and losing it costs the twenty-second window and nothing committed.
AGENTS.md lists what was deliberately not built, and why, so that it is not re-opened.