Releases: bernatch22/taskops
Release list
v0.6.1 — the Stream earns its column
- Open the files the agents name. Every entry now wears the paths it
named, and a click opens that file in the editor beside it — switching to
the card's own worktree when it has one on this disk. Three sources, in
descending order of trust: a commit'snumstatkeys (git's own answer), a
created card's declaredfiles, and what the prose names. - The prose half is a guess and is kept one. Agents write paths to each
other constantly — "the fix site isverbs/_facts.py::pending_mentions" —
and no structured field carries them. So they are extracted, narrowly:
backticked or bare, a trailing::symbol/:line/:line:colstripped
becauseevents.py:53names a file and not a file with that name, never a
URL, never a bare directory, capped. A false negative costs a click; a false
positive is the rail lying about the disk. - Markdown, the drawer's own renderer over the same parser, so a fence, a
list and apathread here exactly as they read there. The 96-character cut
is gone: it landed inside a fence as often as not, and what the reader saw
was not a shortened comment but a broken one. The clamp is now a HEIGHT,
undone by expanding the entry — and expansion is a set, not a flag, because
a reader opens two comments to compare them as often as one. - The rail resizes by hand: a grip on its edge, 240–760px, double-click to
reset, and the width is remembered across leaving the tab. The listeners are
on the document and not the handle — bound to a 6px strip they are lost the
first time the pointer outruns the render. useStreamis the container half, extracted so a second mount of the same
picture cannot come to disagree about what is new or which family is on.- Four mutations, one site at a time. Two assertions were born FALSE and found
by running them: a path cleaner that did not strip backticks, and a "draws
no chips" claim asserted against a mount that HAD an editor. - The bundle grew 341 411 → 345 368 bytes.
v0.6.0 — the Stream, beside the code
- A live rail inside the Editor: every comment, every commit, every
progressa worker reports, as it happens, in a third column you open from
the page's one bar. Shut by default — the Editor's whole decision is that the
code gets the window, and a live column appearing unasked would be that
decision taken back on the reader's behalf. - The socket did not change. A frame is still
{"type":"change","verb","seq"}
and nothing else, which is what lets a public board's feed open with no
credential. The socket pokes; the page reads. eventscan be read forwards:after=<seq>answers exactly the rows
written since the cursor a reader already holds, oldest first, and pages
forward onnextwhen the catch-up is bigger than one page. The cursor is
head— the rowid is still not sent per row. What that replaces is the
arithmetic the client had to do over page one (head - lastHeadrows plus a
seen-set), which was exact only until a burst was larger than a page.- The one log read catches up instead of starting over. New rows are
prepended, so a reader's scrollback survives a board that is moving, and a
quiet board answers with an empty list instead of fifty rows on every poke.
Three surfaces still share one read: the Monitor's pane, the toasts, the rail. - A run of work is one entry. Consecutive events by one worker on one card
within two minutes fold together, counted and sized:
2 commits · 2 files · +41 −7 · progress → 68. Nine rows from one
taskops_planwere the shape this exists to stop drawing. - What arrived is marked and fades by arithmetic — stamped in the reader's
own clock, never the event's, so a comment that reaches the tab late is new
HERE. Nothing is stored: no badge, no counter, no read-receipt. - The reader's place is never taken: entries land on top, a reader scrolled
into history is not moved, and a pill says how many are waiting above. - Filter by family (talk · work · code · review · chapter), follow one card by
clicking its id, open its dossier in the popup every other view uses. - Five mutations, one site at a time, and one of them found a fixture that was
not exercising the rule it claimed to pin. - The bundle grew 330 231 → 341 411 bytes. No dependency added.
v0.5.8 — the Editor is the whole window
- Nothing is drawn above the Editor any more — no KPI rail, no header, no
tab bar. It was a card on a canvas while also being the only view that wants
the entire screen, and the cost was visible: a rounded top edge under a bar
with nothing above it, 24px of canvas down both flanks of a file tree, and a
bottom edge that read as a scroll that was not there. Padding zero, radius
zero, no outer border: the panel IS the window. It had shrunk to a small bar
before, and a small bar is still a lid. - One thin bar inside the page carries the way out, which tree is open and
what the door said about it. It sits outside the panel deliberately: the
refusal and the loading states replace the panel whole, and the exit must not
go with it. The exit is an arrow — a glyph in a bar of monospace facts, with
its words inaria-labelandtitleso nothing is lost to a reader who
cannot see it — andonBackis REQUIRED by the page's props, because this is
the one view with no tab bar and a caller must not be able to strand somebody
on it by omission. Header.compactis deleted: a second shape of that component nothing
could reach any more, still asking to be kept in step.- Four assertions pin the new geometry and each was measured red at its own
site. One of them was born FALSE and is fixed here — it read thestyle
attribute with the pattern's attribute order backwards, matched nothing, and
undefined !== truepassed with the padding put straight back. It now has to
FIND the node before it says anything about it, and that blind spot is
mutated red too.
v0.5.7 — the Editor, and a card that says how far along it is
-
A card reports its own progress —
taskops_update progress=0-100, an
argument on the verb that already changes the card and deliberately not a
twelfth tool. Stored as aneditedevent, so the log, the fold and the
thread renderer read it with no new kind and no new replay rule. A REPORT and
never a state (ARCHITECTURE §3): nothing derives from it, nothing gates on
it,donedoes not write 100, and a card nobody has spoken about carries NO
key rather than a zero — the tile draws a bar only once somebody has spoken.
Guarded exactly as a release is: yours, never a stranger's, refused on a
closed card, and backwards is allowed because a task that turned out bigger
is honest. The cadence rides where a worker actually reads it — the assign
brief, the take's footer and the MCP protocol text — and the write doubles as
the lease's heartbeat, so a worker that reports cannot read as STALLED while
it is reporting. On screen: a track and its number in the tile, one pill in
the drawer,· 35%on the board's text line. -
The board stopped going grey when you switch chapter. Every tile arrived
inverted and the play that reveals them never ran, so the columns drew their
counts over nothing. Three defects, each found only after the one above it
was fixed: a cancelled play was dropped with its styles instead of being
played; rects were measured WITH the animation's own transforms, so the
offsets cascaded (6px → -12px → -6.66px → 12.99px); and the release looked
ids up in a ref map React had already emptied, since children detach in the
mutation phase before a parent's cleanup runs. All three now decided in
components/board/flip.ts, where a headless test can reach them, and
verified in a browser on the only shape that reproduces it — two chapters
that both have tiles: 0 stuck rounds of 6, where the same probe read 5 of 6. -
A sixth tab, Editor (
ui/src/pages/Editor.tsx): pick a worktree — the
checkout first, then every directory under.taskops/trees/— and read its
files as an editor draws them: a folder tree on the left with each file's git
state (M/A/S/U) and a dot on every folder something moved under, tabs on the
right, line numbers, syntax colour for Python, TS/JS, JSON, YAML, TOML,
Markdown, SQL, bash, HTML and CSS, a gutter mark on every line that changed
since the branch base, and a per-file "diff vs base" toggle through the same
patch renderer the Worktrees page uses. Read-only, always. -
Live from local disk: a tree somebody is looking at is scanned once a
second (git for the listing,statfor the measure — no watcher dependency),
the page re-reads only the open tabs that moved, lights the changed lines and
keeps the scroll; a "last change: · " line says the project is
moving. Doors in from every worktree row, the diff page and the dossier. -
The tree folds to the working set — what the branch wrote since its
base (committed,deleted, onegit diff --name-statusper scan) plus
the dirt — every other folder closed; ⌘P/Ctrl+P quick open with VS Code's
fuzzy rule (one definition, also the tree's filter); an Explorer-shaped
sidebar with keyboard navigation, file icons from one inline sprite, a tab
strip that scrolls on its own axis, and the whole page at viewport height. -
GET /<board>/editor/{trees,tree,file,diff,feed}(http/editor.py),
same token door and envelope as/git, served ONLY by a window — the
deployed host says so in one sentence. Files come only from inside the
board's own worktrees;.., absolute paths,.gitand symlinks out are
refused. Caps stated in every answer. ARCHITECTURE.md §22.
v0.5.6 — the hours page draws every hour of a day that opened before midnight
Reported live on /convo/: the hours breakdown was empty. Monday said
17h 13m and drew a single 23:00 row; Tuesday said 3h 32m and drew nothing
at all. Only Sunday — the one day nobody had worked across its opening
midnight — rendered. Every session was in the payload the whole time; the
client had no row to put them in.
components/actors/Daysheet.tsxwalks hour SLOTS, not hour NUMBERS.
core/hours.py's edge rule credits an interval to the day its CLOSING
stamp falls in, so a session opened at 23:5x and closed past midnight is
filed under the NEXT day — and that day's local hours stop being monotone.
The oldfor (h = first; h <= last; h += 1)then walked23..23(one row)
or23..10(no rows). A slot is the local hour's own floor as a timestamp:
monotone across any midnight, advanced through its successor's floor so a
DST-stretched hour is one slot and a skipped one is none. The fold's
identity is the slot too — such a pane can hold two rows labelled23:00.- Verified against the live
/convo/payload, not a fixture: Monday now
draws 25 rows holding all 286 sessions, Tuesday 12 rows holding all 19, and
each pane's total is exactly the sum of its rows. Sunday is unchanged. - Pinned by
ui/smoke/sections/daysheet-midnight-spill.tsx, with the old
walk restored as a mutation and measured red. ui/smoke/sections/actors-window-filter.tsxrepaired — it still pinned
the calendar-month default that 0.5.5's successor replaced with the sliding
30 days, and was red on master.
v0.4.2 — the filter that filtered nothing, and the window that stopped signing in
- "All chapters" is an ARGUMENT now, and it really shows every chapter. The
option sent nomilestone=at all — and an absent milestone never meant "the
whole board": it means "server, resolve the scope yourself", which resolves to
the single open chapter when there is exactly one. So on a board with one
chapter open and seven landed (Berna's, reported 2026-08-18) the page came back
narrowed to that chapter, the menu drew its ✓ beside a scope that had never
been in effect, and clicking the option changed no argument at all — the only
way to see other work was to name every chapter in turn. Board-wide is now
asked for by name (milestone=*,core/types.py::EVERYTHING). Absence still
means exactly what it meant, so no agent and no existing caller sees any
change: it is the dashboard's OPENING state, and the ✓ now falls on the chapter
the server actually resolved, so the pill can no longer claim a scope the page
is not drawn at.taskops_activityrefuses*in the words of its own doors —
a story is one chapter's cards by definition — where it used to answer
milestone * does not exist, a lookup's answer to a question that was never a
lookup. - A window whose session ran out signs in again instead of teaching
taskops join. A host session is twelve hours and a window is a thing you leave open.
Two halves of one hole, both on thetaskops uipath and nowhere else: the
command read the token lying inremote.jsoninstead ofsession.fresh—
which every other command has taken since sessions landed — so a window opened
on yesterday's session was dead on arrival; and the forwarder held the token it
was built with for the life of the process, so a window that was open when the
session expired answered "that credential expired" to every read. Neither path
could come back, so the habit it taught was re-joining: a human pasting a
credential the machine can mint itself from the key it already has. The retry
isRemoteBoard.call's and just as narrow — the SERVER said EXPIRED, and this
process holds a key — and it is a callable passed in, so the forwarder still
knows nothing about keys, files or logins. A window with no way back in (a
standing bearer token, a public viewer) is unchanged: nobody replaces a token
behind its owner's back.
v0.4.1 — the board comes alive
- A comment now SAYS so, on every open dashboard. The UI's one write was
invisible to everyone but its author: somebody commented and no other
session's screen changed. A minimal notification stack now sits bottom-right
over every tab (ui/src/components/toasts/) — the author's avatar, the card's
title and the trimmed text; clicking the bubble expands it IN PLACE to the
whole message, and a separate affordance opens the card dossier through the
sameopenCard. The commented card's tile answers with a brief, subtle pulse
and returns to rest. Nothing is stored and no server changed: there is no
Python diff behind this, no new verb, no second socket and no read-receipt —
the toasts are derived client-side from the same events feed the Event pane
already reads, and "new" is a head delta plus an id set (a toast is shown
once, by event id, ever; the first load is silent, because a page of history
toasted on mount would be fifty notifications about yesterday). Every
timing, trimming and stacking decision lives in a pure module with its clock
injected, soui/smoke/sections/comment-toasts-model.tsxand…-stack.tsx
pin it underreact-dom/serverwith no timers and no jsdom. Colour comes from
theme/tokens.cssalone, andprefers-reduced-motionsuppresses every
animation. - Hours stop falling when a chapter closes: an interval now belongs to the
window its CLOSING stamp is in. No event was ever lost —events.jsonlonly
grows — butcore/hours.py::sessionscounted an interval only when BOTH of its
stamps were inside the window, andreporthanded it exactly the events in
[start, end). A chapter close produces a burst of events; days later the
sliding window's leading edge crossed that burst, every straddling interval
lost its opener and was counted by NOBODY, and WHOLE INTERVALS vanished at once
— the total dropping by more than the elapsed time, which is what Berna read as
"hours being deducted when chapters close".sessions(since=)now keeps an
interval when the stamp that CLOSES it is at or after the edge, whatever its
opener, and the caller's one duty is the fetch:report._fetchreads from
start - hours.GAP, since nothing older than the longest countable interval
can pair in anyway. The rule lives incore/hours.pyand NOT in the feeding,
becausesessions()is the one definition of what an interval is and the
timesheet blocks and the total beside them must stay one pass. The counts
(closed,commits,cards) still stay strictly inside the window: they
count events, not intervals, so the pre-roll is not theirs to see. Pinned by
replaying one log through two adjacent window positions and asserting the sum
changes only by real aging. window=speaks calendar, and an unrecognised spelling is REFUSED. Four
forms, one vocabulary inverbs/_windows.py:Nd(1..90, the sliding figure),
month(this calendar month in the caller's tz),YYYY-MM(that month, closed
on BOTH edges, so a past figure never moves again) andtotal(the whole log,
the figure that only grows). Every one resolves throughcore/hours.py, so
both edges of a span come out of the same zoneinfo walk and never an opening
stamp plus a count of seconds. The olddays()FELL BACK TO 7 for anything it
did not understand — that is how7ddoraugustbecomes a plausible number
nobody questions — and it now raises, naming all four. An open-ended span
(month,total) closes on the next local midnight and never onnow: every
edge is half-open and the event closing the current interval is usually the one
stampednow, so ending there dropped the last block of work from the very
window a person opens to see it.totalcarries no day buckets on purpose. The
resolved span rides on the answer (window: the spelling, the kind, a
printable label, both edges), so a screen titles itself "August 2026" instead
of inferring a month from two epoch floats.- The Actors page anchors on the calendar month, with the window on screen.
It opens on This month — the figure that only grows within it — and carries a
visible filter: 7 days · This month · Last month · Total, the same span flowing
into the per-dev overlay.ui/src/hoursWindow.tsmaps four labelled options
onto four server spellings and repeats none of the arithmetic; what the page
prints about the span is the payload's own label, because a client re-deriving
"August 2026" from two epoch floats is a second calendar in a second language
and a second zone. Exactly ONE spelling is computed in the browser and has to
be —last, "the month before the one the READER is in", emitted as a bare
YYYY-MM— and it takesnowas a parameter so January's2026-00is
testable. The window is an ARGUMENT to the one board call, exactly as
milestoneis: still one fetcher, one coalesced refetch, one snapshot every
pane reads. A board one version behind sends nowindowkey and the old
day-bucket sentence is still drawn — a degradation, not a blank. - The smoke index is ordered by SLUG, not by filename.
-(0x2d) sorts
before.(0x2e), so the first section whose slug prefixed another's —
actors-window-filter.tsxbesideactors.tsx— landed in an order the index's
own list of names contradicted, andtests/test_ui.pysaid so. A page and its
detail are the normal way sections get named; the slug is the identity
everywhere else in that generator (the import path, the index key, the
duplicate check), so it is now the identity the order is over too
(ui/smoke/sections.mjs). - A milestone may DECLARE its seam files, and sibling appends to them stop
being conflicts. Three conflicts in one real wave were the same mechanical
thing: sibling cards each appending their own entry to one shared registry
file. That is not judgment, it is git's built-inuniondriver. So
taskops_plan union_files=[…](andtaskops_update milestone= union_files=[…],
the whole list, likerules) records the paths on the CHAPTER, they are folded
per read (core/chapters.py) and ride on the card and board payloads with no
second copy, andtaskops_mergehands them to the catch-up merge. Only those
paths union; every other conflict aborts and refuses exactly as before, and a
chapter that declares nothing runs a byte-identical merge. The mechanism is
EPHEMERAL and lives outside the repository — a temp file passed as
git -c core.attributesFile=…for that one process, deleted in afinally
whether the merge landed or aborted. Never a committed.gitattributes, and
never$GIT_DIR/info/attributes, which every linked worktree of this repo
SHARES (measured) and which a crash would leave enabled repo-wide. A committed
attribute still wins on precedence, so the dashboard bundle's-mergecannot
be overridden by a declaration. The worker is told: the take prints the seam
files under the chapter's rules, with the only edit shape a union can fold —
APPEND, never restructure. (gitwork/catchup.py,mcp/integrate.py.) - A STALLED row says how its holder went quiet, not just that it did. A
session limit took five workers at once and every stalled row read "quiet for
1h", so deciding resume-vs-reassign meant opening each card. Stalled rows now
carrylast_event = {kind, ago}— the last event on the card's thread — and
commits, the number of commits bound to the card, so "handed over and never
heard from" (edited), "claimed then silent" (claimed), "said one thing then
gone" (comment) and "three commits on the branch" are four different lines.
Both are DERIVED per read (verbs/_rows.py::forensics) and ride on the stalled
group alone, the waywaiting_onrides on blocked: nothing new is stored, no
cause of death is recorded — the lease's only heartbeat is MCP traffic — and
every other board row is byte-identical to what it was. The MCP board prints
them on the STALLED line (mcp/boardview.py). criteria_met=falselands a chapter, with a mandatory note on the record.
The landing gate read the answer as a bool, so "absent" and "false" collapsed
into the same refusal and a criterion that is structurally post-landing —
"seven days of live rows" for code that only deploys FROM the trunk —
deadlocked the chapter or invited a lie. It is now THREE states: absent
refuses and shows the criteria,truelands, andfalselands too, but never
in silence —note=saying which criteria are unmet and why landing is still
right is required, in the gate (mcp/chapter.py::land, before any git runs)
and again in the write (verbs/record.py::merged). Both the answer and the
note go into thelandedevent. The schema's own words hold: recorded, never
judged.- Two refusals stopped naming the wrong actor and the wrong move. A catch-up
conflict on integrate used to say only the worker could resolve it, in its own
worktree — but the orchestrator is usually the one integrating, and the path
it needs is the CARD's worktree, which it cancdinto. It now names that
directory and says "whoever is integrating — orchestrator or worker"
(mcp/integrate.py). And the default REVIEW guidance no longer suggests
spawning a verifier sub-agent: reviews are done in-session, so the board and
the MCP instructions send the reader totaskops_review task=themselves
(mcp/boardview.py,mcp/server.py). - Pinned: a comment on a CLOSED card is accepted; only the mention is
dropped. Reported as a refusal, it never was one — the log is append-only,
so a postscript lands and does not reopen the card. What DOES stop at the
close is delivery: amentions=written on a closed card pages nobody, and
silently. Both halves now have tests and the asymmetry is written down
(verbs/_facts.py::pending_mentions, CLAUDE.md). - **Documented: a lapsed lease still closes ...
0.3.3 — a clone joins with two words
taskops joinreads the address the clone already carries. v2 committed
.taskops/board.jsonexactly as v1 did — and never read it back: a bare join
resolved through the per-machine recorded remote, so a fresh clone died on
"which host?" while holding the answer in its tree. Restored: inside a clone,
taskops join(ortaskops join --github, first time) is the whole
onboarding.taskops remote addremains for a checkout that carries no
address, or a join onto a different board.
0.3.2 — the window's address, prose that reads as prose, and a link that stays inside
taskops uiopenshttp://127.0.0.1:<port>/. It used to hand out
/board/ui/?token=…, which leaked two implementation details into the one URL
a human types —boardis only the name a window mounts its single board
under, and/ui/was a door on a HOST, which has served no page since the
dashboard moved to the binary. Worse, the page then rewrote the address bar to
a path recomputed from that mount, so a refresh landed on/and got a JSON
404. The board's own routes are unchanged and still hang off/board.- A markdown report is rendered as markdown.
.mdreports — the ones written
to be read — were served as their own source, hashes and pipes and all: the
door typed themtext/plainand the reader dropped them in a<pre>. The door
now answerstext/markdownand the dashboard draws them with the same renderer
a card spec and a close note use. It needs no sandbox and gets none: that
renderer builds React elements and emits no HTML, so raw markup inside a report
is drawn as the characters it is. A self-contained HTML report still goes in
theallow-scripts-only frame, and an unrecognised type still degrades to
plain text. - The card's Worktree block opens the worktree, in the dashboard. It offered
a forgecompare ↗— two branches on somebody else's website, rendered for a
clone that may not be yours. It now opens the worktree diff view, reading your
own clone, with the card's thread beside it. It needs no forge slug, so a board
with no GitHub at all has it too. - The chapter goal pane got a taller clamp — a long goal scrolls less.
0.3.1 — three chapters: reports, GitHub as an introduction, and the whole lifecycle
- Reports. A milestone's narration becomes part of the board:
taskops_activity
reads the whole story of N cards at once,taskops_filedrecords a report as
{path, title, milestone, sha}— the CONTENT stays a file in.taskops/reports/
and the list is a fold over the log, never a table. The dashboard lists a
milestone's reports and renders one in a sandboxed iframe, served by a read-only
/gitdoor that hands back a committed file at a rev. - GitHub is the INTRODUCTION, never the credential.
taskops join <board> --githubenrols your ssh key when you have push on the repo the board declared.
The token is asked for once, travels in one request body, and is stored nowhere;
what persists is a pubkey. A board opts in withtaskops board forge <owner>/<repo>and opts back out with--clear— owner only, both ways. Absent
a forge (how every board is born) that door does not exist. - The lifecycle runs backwards too.
taskops board pullbrings a hosted board
down as a snapshot — the same five steps aspush, in reverse, over paging — and
taskops board rmtakes one off a host.rmREFUSES to destroy a history this
checkout does not hold, judged on the host against the board's real event ids,
and says which of the two ways out you want. There is no--forceand
--discard-historyis not an alias for one. - Fixed: the dashboard flooded a remote board with requests. The forward
published a change frame on any answer carrying aseq, and every envelope
carries one — so every READ announced a change, the page refetched, and the loop
ran at coalesce speed. Reads no longer poke anybody. - The
taskops uiwindow is a lease, not a pidfile. Aflockthat dies with
its holder, an identity check on/healthzbefore a browser is reopened, and a
server that retires itself when no tab has been open for thirty minutes. - Fixed: a minted secret can no longer start with
-, which made roughly one
invite in 64 unusable as a CLI flag value. - A client that hangs up mid-response is no longer printed as a crash.