Releases: jcanton/openproj
Release list
v0.60.3
A code block has one ground, not one per line. .doc code is the rule for an
identifier in running prose — a tint and a quarter-em either side. A <code>
inside a <pre> is that same element and inline as well, so it took the same
tint and painted one box per line box, each as wide as its own text: a fence came
out as a ragged stack of grey bars with the indentation showing through as page,
and a blank line inside the block drawn as a gap in the middle of it.
The ground moves up to the block and the inline rule is switched off inside it,
everywhere a document is drawn — a record's body, the preview beside it, a
cycle's notes and a deck slide. A fence wider than the column now scrolls inside
its own box rather than the page scrolling sideways.
A slide is asked for a code block, not searched for one. The deck test that
watches for a stray code block read "<pre>" not in page — and every page inlines
its stylesheet, so it was reading CSS as content. It parses now.
🤖 Written by an agent on behalf of @jcanton
v0.46.0
The hover card — the box that opens over a title, a graph node or a timeline bar — now starts every document at the top in both engines, and its height is the reader's to set.
The document starts at the top
Scroll one pitch to its end, hover the next record, and the card opened that record's shaping document at the previous one's offset.
One box is drawn for every record hovered, so a scroll offset belongs to the box rather than to the document in it. Two things were wrong. fillCardBody reuses the element when two answers land for one card — a cached body and a fetched one — and nothing put the offset back. And the reset that fixed that was written one line too early, on an element not yet in the document, where there is no scroll frame to set an offset on at all.
The second half is why this was a Firefox report. hidden on the way out is display: none, which destroys the scroll frame the document is read in: Chrome drops that frame's offset, and Firefox saves it — keyed by where the box sits in the card rather than by the element — and restores it onto the frame built there for the next record's document. The element is new; the offset belongs to a document nobody has opened. The reset now comes after the layout that applies the restore, and is the last word.
The bottom edge is a handle
8em keeps a card from covering the table it was opened from, which is the right default and the wrong ceiling for somebody reading a pitch on a large screen. Drag the card's bottom border:
- floored at the default, so the cap stays written in exactly one place;
- clamped at the foot of the window, measured against the card's bottom and not the document's — the handle is on that edge, and clamping the document put it below the window where nothing could reach it;
- double-click to put it back, the way the deck's rail is;
- remembered across cards and across reloads, and clamped again on the way in, so a height dragged on a tall screen cannot open a card off the bottom of a short one;
- drawn only where there is something to reveal.
Firefox is driven by the suite now
tests/marionette.py — fifty lines of Firefox's own remote protocol over a socket, no new dependency — and the reported sequence is asserted in the engine that could not keep it. A suite that drives one engine cannot see a disagreement between two, and this release is what that costs.
🤖 Written by an agent on behalf of @jcanton
v0.18.1
A patch release for one fix. v0.18.0 was already tagged at fd87881, and a tag here names a commit rather than moving to one.
/api/health returned 500 on a plan with no commits yet (#95).
condition() reads repo.references[refs/heads/main], and pygit2.init_repository leaves that ref absent until the first commit — so the one route whose whole job is to say the service is alive was the route that raised, on exactly the plan a first-time reader points the tool at.
Healthy and honest is the answer: nothing is committed, so nothing is unpushed and nothing can have diverged. head is empty rather than invented.
This was the second reader of that line — the first, in Store.__init__, was fixed when a credential test happened to build such a repository for an unrelated reason. Nothing in the suite had asked condition about an empty plan. Now something does.
🤖 Written by an agent on behalf of @jcanton
v0.18.0
Five words that mean something narrower here say so
Appetite, thinking, shaping, shelved and cycle are the words a newcomer reads as ordinary English and gets wrong. Each now carries one sentence in the <dd> under its own control — FIELD_TEACH and STATUS_TEACH in render/tokens.py, where the comment carries the rule a line had to pass to be written at all: does it change what somebody does at that moment? title, tags and owner do not. Five, not fifteen.
Edit-only, and that is not a size argument. Since the record page landed on preview, a read is roughly nine views in ten — and teaching copy on all of them is how the one sentence in this slot that is a fact about the record, the derived-status lock, stops being read. Two tenants, two spans, two rules.
The status sentence follows the ball, not the saved value. Status is one field with six places to stand, so the copy belongs to the stop: somebody dragging onto shelved is deciding what shelved means, and a line about where they came from is help for the wrong decision. attachHill swaps it inside show(), which is also what makes an abandoned drag put the old sentence back. The three words with nothing to teach get an empty span, because the script cannot fill an element that was never rendered.
shaping was rewritten for this release. With thinking below it since v0.17.0 it no longer means "nobody has looked at this yet" — it means somebody is looking, and narrowing.
One line is not a hint. How concrete a solution should be is decided while the Solution section is written, so the too-vague/too-concrete symptom pair went into the pitch template's own HTML comment: invisible under preview-first landing, visible to the writer, deleted as the section fills in.
Everything is paraphrase, never quotation.
CI gates every pull request
on: pull_request carried branches: [main], so a PR based on any other branch matched no trigger and gh pr checks answered "no checks reported" — the same words it uses while a run queues, on a PR reporting MERGEABLE/CLEAN. A stacked branch sat un-gated and looked exactly like one waiting its turn. Empty must not look like broken.
Gating it earned its keep on the first run: it found a real defect this branch had shipped. aria-describedby had become a token list, and an assert written when the status lock was built could only read it as a single id. The claim it disproved was mine — that the lock and the lesson can never share a row. True of today's data, and written down as though it were a property of the code.
🤖 Written by an agent on behalf of @jcanton
v0.17.0
thinking is where a record starts
A status at the foot of the hill, on every kind but an issue, and the status a new record opens on.
It already existed as a note's word with a hill stop at 0.0, so this is a widening rather than a new rung: STATUS_ORDER gains it first, the three planned rungs share that tuple by identity, and _status_class, _ordered, required_at, HILL_LADDERS and _TOC_LADDER all follow with no edit. No geometry moved.
An issue keeps its four words and refuses it in a sentence — an issue is something somebody noticed and reported, which is already more thought than thinking claims. A note keeps thinking and dropped.
Three things did not derive and had to be written: the glyph ○, a colour slot so --st-thinking exists in all three themes instead of folding to st-ready, and docs/quickstart.md, which said "Five, in the order work moves through them".
The graph legend
A sixth status key made the two rows unequal. They pair from the right now, so the last key of each shares a column and the shorter row's name cell takes the slack — and status leads, because it is the longer row and the shorter one hanging under its right end reads as one block rather than a step.
That needed _GRAPH_STYLE rendered rather than passed: _page takes a style as a finished string, so a {{ }} left in the constant was literal text in the CSS doing nothing.
🤖 Written by an agent on behalf of @jcanton
v0.16.0
Four pull requests since v0.15.0. Three of them were losing work or hiding it.
A wedged plan says so (#89)
Store refuses to auto-resolve a diverged history — right, and unchanged. But web.py had WRITE_FAILURES naming exactly that family and used it in one place, the co-editing socket, and grep -c exception_handler web.py returned 0.
So every one of the eight HTTP write surfaces answered a bare 500 Internal Server Error — for the life of the container, including writes to files nobody had forked — while GET / answered 200 and /api/health returned a hard-coded ok: true. A permanent write outage every uptime check passed straight through.
Now 503 and a sentence on all eight, saying what to do rather than only what happened. /api/health reports the local condition and the unpushed depth, and clears without a restart.
409 would have been worse than the 500: it is the code the page already reads as an ordinary edit collision, so the browser would have drawn the conflict box and invited somebody to resolve a conflict that was not theirs. Mutating it to 409 fails eleven tests, eight in the browser half.
Verified in real Chrome on a real socket: the page does not say "saved", it announces the whole sentence, and the typed work stays in the box.
A reader is offered no door they cannot walk through (#88)
editable means "there is a server behind this page" and was standing in for "this person may write". Signed out, the Create button was drawn and opened a form with every control on it hidden.
A container is as far along as the work beneath it (#87)
size_weeks says a container "has no size of its own" and then returned the default task effort anyway. A product holding a project worth five weeks reported 0/0.5 wk under a meter reading "0 per cent of this bet is done". The demo plan now reads 7/31.
🤖 Written by an agent on behalf of @jcanton
v0.15.0
Thirteen pull requests since v0.14.0, and the repository went public along the way.
| CI | 22m50s → ~2m30s |
render.py |
20,556 lines → a package of 19 modules, byte-identical output |
| vocabulary | no Entity anywhere; /api/record, no redirect |
| docs | 12,953 lines of archive retired, 146 folded into the live docs |
| corpus | 4 of 6 rungs → all six, with cycles and a promotion chain |
Three data-loss defects fixed
Each found by measurement rather than by reading:
_merge_bodydropped one of two edits beginning on the same line and answeredmerged. 48% of same-start pairs lost a line — half of them the line already in git, so a colleague's committed sentence was reverted with nothing shown. It refuses now.- Every HTTP write answered
200with no way to say the commit had not left the instance.pushedwas set honestly and read by exactly one caller in the application. - The deck's title slide printed the cycle's whole body where it meant the
goalfield, so an eleven-slide deck printed on twelve sheets.
Two performance fixes, both invisible until something measured them
Environment.from_stringrecompiled fourteen Jinja templates per record — a 479-record export made 6,739compile()calls. One served/detailwent 60 ms → 5 ms; a full export 43.6 s → 0.74 s._detail_rowsbuilt a full markdown render for every record in the plan and kept one: 63% of the server's CPU under twenty readers, discarded.
A concurrency audit
Six load scenarios and ~two dozen probes, committed under tests/load/, reported in docs/probes/concurrency-audit.md. The plan repository came through ~1,800 accepted writes with no conflict marker, no fork and nothing unpushed. It breaks first on CPU, on the read path — not on locks.
🤖 Written by an agent on behalf of @jcanton
openproj 0.14.0
Every record is the same kind of thing now, and the plan opens on a list of them.
One record type
Issue and Note were standalone models with their own pages, their own routes and their own readers. They are the fifth and sixth rungs of the ladder Rung/KINDS already was. No file-format change and no backfill: kind already fell back to the id prefix, and the corpus ids were already issue-… and note-….
What kept an issue off the table, the graph, the timeline and the people page used to be its type. It is now Rung.planned, a narrowed Index.entities, a validator on the index, and a KINDS-derived sweep that asserts every unplanned kind is absent from every plan surface — so a seventh rung is covered the day somebody adds it. The old argument was that a separate type achieved this "by construction, rather than by an exclusion in each of them that somebody later forgets"; the replacement is stronger, because a forgotten filter used to fail open and a forgotten consumer now fails closed.
One record page
_ISSUE, _NOTE and _NEW are gone. _DETAIL serves all six kinds and the create form. Issues and notes gained everything they never had — the draft store, the conflict box, co-editing, Delete, the status hill — and render.py shed about 1,400 lines.
The editor's three views lost their unnamed fourth state. view is the sessionless landing view, so a record opens rendered and Write is one press away instead of one page away.
One list
/ is Records: kind, title, Who, tags, last modified, newest edit first, with the search box it already had. The table moved to /table and stays what it is. /issues and /notes are filtered views of the same page, one template and one renderer between them.
Last-edited is walked out of git history — no stamped frontmatter field, because that needs a backfill, is derived data in a file, and lies about anything edited by hand and pushed. Full walk once before the server binds, forward-diff per commit after.
Bugs fixed on the way, all of them older than this release
- An idle signed-in reader took a co-editing seat merely by opening a record, appeared to everyone else as "also editing", and held a room, a watch and an outbox task per record visited.
ID_PATTERNwas missingprod, so a product could be created and then neither patched nor deleted.- A hand-written
depends_onorparentnaming an issue put an inbox id into the table, graph and timeline payloads — and drew it. Both resolve against the plan now and refuse the gesture at pick-up. - A stale socket's queued
onclosecould arm a reconnect beside a live one, giving one person two seats.
Upgrading
The landing page is a different page than it was, the table is at /table, and the static export writes a different set of files. Nothing in a plan repository changes.
🤖 Written by an agent on behalf of @jcanton
openproj 0.3.0
The first release with a deployment behind it. 189 commits since v0.2.0, and
the running revision now has a name instead of a sha in a deploy log.
Planning
- Cycles are records. One page per cycle with its bets and its capacity, one
Save for the whole betting table, and availability you can set while you watch
what it does to the numbers. A cycle is two meetings and the rest is
arithmetic. - A size is person-weeks, and the people on a task divide it. Every date on
every page is derived from one typedassigned_onand one size — nothing
computed is ever written back into a file. - The review deck for a cycle is generated from what happened, not retyped
into slides. A slide never says nothing, and never quietly says half. - Issues and Notes are two inboxes, because they answer different questions:
an issue is "something existing is broken", a note is "we are thinking of
something that does not exist yet". Promote turns a note into a project, a
pitch or a task, and an issue into a pitch or a task, in one commit — and the
new record says in its own shaping document where it came from.
The views
- A plan is a tree, and the table draws one. Rows can be dragged onto each
other to file them; the graph can do it too, in a mode of its own, with the
drop refused before it is sent if a task would end up holding a task. - One hover card, three views. The timeline had it; the graph and the table
have it now — the record's fields plus its shaping document, fetched on hover. - The search box is a small language:
field:value,and,or,not,
brackets, and two words side by side meaning both.tag:gpu and tag:distributed
is the query the dropdowns cannot express. It is parsed on the server and in
the browser, and the two are pinned together by results — including the
sentence a malformed query is refused with. - A filter can hold two values. Two statuses means either of them, which is
what the filter underneath has always answered. - Statuses are a luminance ladder with a glyph, not five hues: hue is the
channel a dichromat loses, and on the graph and the timeline the fill used to
be the only channel there was.
Writing
- Two people can write one shaping document, and it is still one commit.
- Every write is a commit on a real branch by the person who made it. A write the
model cannot read back is refused with a 422 that names the field. - A file that is not a record costs that file and nothing else. Fifteen ways
of writing an unparseable plan file used to take ten of eleven routes down for
everybody; each is now named on the page, beside the plan that did load.
Running it
openproj demoserves the bundled corpus offline from a throwaway repository.- A Dockerfile, a one-script Cloud Run deploy and a runbook. No npm, no build
step, no CDN: every library is vendored and checksummed, the typeface is a
data:URI, and no page ever asks the network for anything.
Upgrading: nothing to do. The plan is markdown in git and this release changes
no file format.
🤖 Written by an agent on behalf of @jcanton