# Changelog All notable changes to `@bzdos/hubd`. Dates are release-commit dates. The file format (markdown + JSONL, append-only logs) is the stable contract; a version here never migrates or deletes data. ## 0.9.53 — 2026-10-06 - **`mesh-sync` fetches again when another node's push lands first.** Every node pushes to one mirror about once a minute, and a push that lands between this node's fetch and its push wins: git refuses ours ("fetch first"), or the mirror cannot lock the branch it is moving. One node lost one run in six that way, about 90 a day, each a minute of delay and a failed unit in systemd's log. That failure, and only that one, now goes back to the fetch in the same run, up to `HUBD_SYNC_PUSH_TRIES` times in all (default 3). Any other failed push is left to the next run, as before. - **A node's presence snapshot says when hubd last ran there.** `presence..json` was written on heartbeat only, so a node whose sessions never heartbeat (people at a terminal, not agents in a loop) kept the one it wrote weeks before: from every other node it looked silent for 24 days while it wrote to the mesh every minute. Any MCP tool call now refreshes it too, at most every 5 minutes. A hub that has never published one gets none. - **`hub doctor` waits for the hub's git.** Its checks read git with an 8 s bound and took a read that ran out as an empty answer: with every core busy, `git ls-files` once took longer, and a case-colliding pair came back as no pair. Reads of the hub's own repository now get a minute; reads of project repositories keep 8 s. ## 0.9.52 — 2026-10-06 - **`hub doctor` says when a merge driver will not run.** The drivers live in the node's own `.git`, and a missing one or one whose path went stale fails without a word: the merge just falls back. On a node that syncs, doctor now names each driver that is not installed, lacks an attribute line, or names a file that is gone, says what a merge does instead, and counts it as a warning. A command set by hand is not checked. - **The driver command names `node` as PATH has it.** It named the binary with every link resolved, which on one node was `/usr/bin/node-22`: gone with the next major version, while `/usr/bin/node` stays. Now the first `node` on PATH that is the same binary is named. Run `hub card merge-driver` again on each node to repoint it. ## 0.9.51 — 2026-10-05 - **A file one node rewrites whole no longer stops the mesh.** `snapshot..json`, `presence..json`, `sense..json` and the read marks in `queues/read/` have one writer each, so two versions meet only after history was repaired or rebased, or when one node name is in two clones. git's text merge then stopped on every line with a time in it: one node's snapshot stopped another node's sync until its history was realigned by hand. `hub card merge-driver` now also installs a driver for these files that takes the version with the later time (`ts`, `written`, or the latest read-mark `at`), whole. Ours and theirs swap between a merge and a rebase; the time does not, so both keep the same version. A version whose time does not read loses; equal times pick the same version on every node. If the driver's path goes stale, the file keeps git's ours. Run the command again on each node to get it. ## 0.9.50 — 2026-10-05 - **`mesh-sync` packs the hub itself, once a run, in the foreground.** git packs after a commit, a fetch or a merge, in a process it forks into the background. On macOS that process crashed after the fork and left `.git/gc.log.lock`, and every background gc after it stopped on that lock without a word: one hub went seven weeks unpacked, 86000 loose objects and 3.75 GiB, and every run redid the part of gc that comes before the fork, once for each command that started one. git's own gc is now off for the script's commands (`maintenance.auto=false`, read by git 2.31+), and one `git gc --auto` runs after the push, its errors in the sync's log. A failed push still packs. A foreground gc never takes the lock, so a hub that has one needs nothing done to it; its first run packs the backlog, which `git -C gc` by hand does ahead of time. ## 0.9.49 — 2026-10-05 - **A queue read on another node no longer counts full there.** The send limit took a block as read only by its own ack, so blocks written before ids, which no ack can name, and header lines quoted inside a body stayed unread for ever. A reader hands its file out in order, so a file now counts as read up to the last block its ack log names, every block above it included. - **A reader publishes its position even when nothing new came.** A reader that read its queue to the end on a hubd from before read marks left none, and its cursor never leaves its node: every other node counted that queue unread until the next message, which a queue counted full refused. Each wait now writes the cursor's watermark as the read mark where the two differ. - **The hub sets the send limit in `limits.json`.** `{"queue": {"msgs": N, "bytes": N, "exempt": [role, ...]}}` travels with the mesh; `HUBD_QUEUE_MAX_MSGS` and `HUBD_QUEUE_MAX_BYTES` set on a node still win there. A role in `exempt` is never refused nor listed as near full: one whose reader reads the file itself, leaving no cursor, mark or ack. `hub doctor` names the exempt roles. ## 0.9.48 — 2026-10-05 - **Artifact deliveries are mail, a row of their own.** A mail relay (a fleet tool) records each delivery that succeeded as a journal entry of kind `delivery`, through `hub report -k delivery`: ` → : B sha256 ` (the byte unit may be the Cyrillic one, the arrow `->`). Live shows the newest 20 in a Mail row above Activity, which no longer lists them; `/api/kanban` and `hub_kanban` gain `mail`, each delivery read into `from, to, name, bytes, sha256` beside its whole text. On the Summary each track has the newest 20 whose recipient is one of its roles (`/api/summary` gains `mail` per track, and `mailLimit`). An entry of the kind whose text does not read so is kept whole with the parts null: Live shows it, and no track claims it. `hub report` takes any kind, so the relay needs no change. ## 0.9.47 — 2026-10-05 - **The Summary shows each node as its own snapshot states it.** A node's fleet tool writes `snapshot..json` (v1: sessions, disks, relays) into the hub directory, and the mesh carries it like `presence..json`; hubd only reads it. `/api/summary` gains `nodes`, one row per file, and `snapshotStaleMin` and `diskFullPct`. The page lists each node's snapshot age, disks and relays, and a table of sessions: session, node, role, state, minutes since the hub, minutes since motion. In red: a snapshot over 5 minutes old (or with no time), a disk at 90% or more, a relay failed or not active. Fields it does not know are ignored; a file that does not parse, is not an object or is not v1 is a row "snapshot unreadable" with its node and the reason. ## 0.9.46 — 2026-10-05 - **An escalation to the fleet waits until it is answered, and the board says which.** An escalation is a block with an id in the queue of a `fleet` role, and its key is its header, ` · from · id N`. It is answered when an entry of the fleet card's Owner decisions section quotes that key; the entry's stamp is the answer's time. Matching is by the key alone: "id 3" is not "id 32", and no author or wording is read for an answer. Entries the section cap moved to the project's history still answer, and of two answers to one key the later stands. - **"Waiting for you" lists every unanswered escalation, however old, and those answered in the last 24 hours** with the answer, in `hub board`, the Tracks view and the Summary (`/api/summary` gains `escalations: {fleet, waiting, answered}` and `answeredHours`). Each carries its key, who sent it to which role, its text, how long it waited and, once answered, when and what. Queue files are read whole for this, not their last 256 KB. This replaces the window of escalations and of the fleet role's own replies, which said nothing about whether the owner had answered. - **A section key `owner-decisions`** (`## Owner decisions`), not in new cards, localised by `sections.json` like any other: `hub section add owner-decisions " — " --by `. ## 0.9.45 — 2026-10-05 - **`hub serve` opens on a Summary: where each track stands, assembled by a script.** Asked "where is each track", a coordinator retold the board, and two retellings of one state differed. `/api/summary` answers from the files: per track, the goal, the tasks in work, blocked and closed in the last 24 hours, each with its age, its assignee and the head's newest verdict entry in full, and each role's newest `blocked` entry until the role hands that task in or the task closes. Lists are ordered by their own fields, so a fixed hub at a fixed moment gives the same bytes. It is the first screen of a hub with heads; a mode chosen before still wins. - **A verdict is a head's entry that names the task and holds a verdict word** in capitals: ACCEPT, ACCEPTED, REJECT, REJECTED, plus the words a hub lists in `/verdicts.json`, `{"accept": [...], "reject": [...]}`. At one position the longer word wins, so a negated form listed under reject is read whole. A task is named by `#`, or bare when its id has a letter: a bare number in prose is a count. In an entry that opens with a verdict, a task takes the word before its name ("ACCEPT #12, REJECT #13"); in one that names a task first, the word after. - **A card's `goal` section** (`## Goal`), which the Summary quotes; without one, the digest's first line. New cards do not get it: a head writes it with `hub section add goal "" --by --set`, and `sections.json` localises its heading like any other. - The board and the Summary share one definition of a track (`trackLayout`). ## 0.9.44 — 2026-10-05 - **`hub_reflect`: the reflection digest over MCP**, what `hub reflect --json` prints: `project` (its aliases count as it), `since` (default 7d), `level`. A head reads it instead of its workers' reports in full. Every key is present with zeros; a capped reply drops `decisions` first, then `rules`, and says so. - **A rule that holds the fragment's own example is not a rule.** On one hub, two roles put the example's rule into 86 of 2768 reflections: 45 times alone, 41 times after the fragment's own lines restated. The `reflect` field refuses it, with the reason. A `REFLECT` block that holds it is read, and the reply names it a problem. `hub reflect` counts it as `exampleRule`, never among the rules, and `hub recall` finds no rule in it. - **The fragments say it, and the head reads the digest.** `reflect`: the `rule` is what this turn taught, in the role's own words; the `reflect` field is named as the other way to give the block; the example says its rule is not the reader's. `head-cycle`: step 1 reads `hub_reflect`, step 5 reads its obstacle counts and its rules. Every role's rendered rules differ from the render until rendered again: `hub prompts render … --check` exits 1 on each. - **`hub watch --exec `: each entry to a command, marked when it exits 0.** The entry goes to `sh -c ` as one JSON line on stdin, so a delivery that fails is retried: it is delivered at least once. A non-zero exit, or a command killed after 20 s, stops the pass at its entry; it and the entries after it are handed over again on the next pass. One pass then exits 1. `--follow` says so once, retries each interval, and says when the command exits 0 again. The cursor is saved after every entry. On SIGINT, SIGTERM or SIGHUP the command in flight finishes and the watch exits 0. `HUBD_WATCH_KEY` is the entry's key, the same on every attempt, for a receiver that drops repeats. `--exec` with `--json` is an error. - **`contrib/watch-to-matrix.sh` runs over `--exec`.** The Matrix transaction id is `hubd-$HUBD_WATCH_KEY`, so a retried post shows once. Before, the id changed on every attempt: a post that timed out after the server took it was shown twice. An entry the script is killed holding was lost; now it is posted on the next start. - **Docs.** HUBD.md names `hub_reflect` and the `reflect` field. It pointed at `hub get `, which the CLI does not have; it now names `hub_get` and the card file. The quickstart lists mesh-sync's exit 5 (a merge git refused before merging) and shows `hub reflect` and `hub watch`. The interop doc describes `--exec`. ## 0.9.43 — 2026-10-05 - **`hub watch --as `: the journal's new entries, each once.** For a notifier that follows the journal. Reading the files by byte offset shows an entry twice after a union merge doubles it, and misses one a merge puts before lines already read. It replays a file that a reset shortened and a pull grew back, and it loses the lines cut into a month archive by rotation. The cursor (`.watch/.json`, node-local, never synced) holds no offset. It holds a short hash of each entry it passed, keyed by node log and line, as the hub's readers drop repeats. One pass and exit, or `--follow` (a pass when a journal file changed, `--interval`, default 5 s). `--json` prints one entry per line. A new cursor starts at its first run, or earlier with `--since`. Hashes are kept for 7 days. `-p` narrows what is shown, and the private braid is shown only with `--private`. An entry is marked once it is written, so a reader that goes away misses nothing on the next run. Measured on a week of one hub's journal, 11593 entries: the first pass took 1.7 s, a pass after that 0.7 s, and the cursor holds 154 KB. - **`contrib/watch-to-matrix.sh`**, a bridge to a Matrix room over `hub watch --follow --json`. It retries a post that fails, and it is now in the package. - `.watch/` joins the node-local lines a writing command puts in `.git/info/exclude`. ## 0.9.42 — 2026-10-05 - **A reflection is data.** `hub_report` takes a `reflect` field: `goal`, `result` (done | partial | no), `obstacle` (permissions | path | unclear-dispatch | environment | model | none) with `obstacle_fact`, `instead`, `rule`, and a `level`: `turn`, or `head` and `fleet`, which may carry `decisions` on proposed rules (accepted | rejected | needs-owner). A value off a list is refused before anything is written, and so is a reflection given both as the field and in the text. The journal entry keeps the field and the same block as text, so a reader of the journal sees what it saw before. The CLI takes it as `hub report --reflect `. - **The `REFLECT` block in a report's text is read, not refused.** Reports written to the fragment keep working. The reply says the reflection was read and names what is off the lists: measured on one hub's journal, 24% of 2691 reflections had something, most often a fact after `none`. - **`hub reflect --project

[--since 7d] [--level turn|head|fleet] [--json]`.** Per role, the reflections and their results; per obstacle class, the count and the latest three facts; the rules proposed more than once, compared without case and punctuation; the decisions a head or the fleet took. A result or class off the lists is counted as `other` or `unclassified`. The `--json` keys are always there, at zero when empty. - **`hub recall` finds a reflection's rule and obstacle as hits of their own.** They end the entry, so inside the journal hit they sat past the 300 characters a hit shows. ## 0.9.41 — 2026-10-05 - **A role's rules over MCP.** `prompts/list` now names the worker, head and orchestrator templates next to `harvest`, each with its declared variables as required arguments, described from the tables in `prompts/meta/README.md`. `prompts/get` with all of them returns byte for byte what `hub prompts render` prints, rendered by the same code from the installed package, so a client with no rules file gets the current rules, not a copy. A missing or blank argument is an invalid-params error (-32602) that names it; a broken template is an internal error, and `prompts/list` then lists `harvest` alone and says why on stderr. - A template variable without a row in `prompts/meta/README.md` now fails a test: the MCP argument would go out without a description. ## 0.9.40 — 2026-10-05 - **A conflict is named as one in any locale.** `mesh-sync` tells a refused merge (exit 5) from a content conflict (exit 2) by git's own words, and git translates them. On a node with a Russian locale a card conflict was reported as a failed pull, and that node's mesh stood for 2.5 and 4.5 hours. The script now runs git under `LC_ALL=C`. - **Cards merge by section: `hub card merge-driver`.** Run once on a node, it installs a git merge driver for `projects/*.md` in that node's `.git/config` and `.git/info/attributes`. A section is a `##` heading and what follows it. Sections changed on different sides merge without a conflict. A section changed on both sides gets a line merge of its own; when that clashes, both versions are kept, the node's own first, under a line asking a person to look. Two writes of one card keep the later `- set:` stamp. Measured on a mesh, 72 card merges in nine days: git stopped on 42. With the driver, 4 of those merge clean and 38 are kept and marked, and no line is lost in any. Nothing travels with the mesh, so a node without the driver merges as before. If the driver's path goes stale, the merge falls back to git's union merge; run the command again. `--remove` takes it out. - **A shared hub stays group-writable after a sync that stops.** Group write was restored only after a merge that changed HEAD, but a failed fetch or merge has already written into `.git` as the sync's user. Those paths restore it too. - A node that runs a copy of `mesh-sync.sh` gets the locale and permission fixes only when the copy is replaced. The driver runs from the installed package. ## 0.9.39 — 2026-10-04 - **A failed mesh merge no longer hands a queue out again.** `mesh-sync` merged in the live hub dir. A merge that stopped on a conflict in other files stood open until the abort, and every file it touched held the other side's version meanwhile. A reader took a block from a queue file; the abort put the shorter local version back, the cursor fell to 0, and the worker got its whole queue again, on every failed run for over an hour. Two fixes, either enough for that case: - `mesh-sync` fetches and tries the merge outside the working tree first (`git merge-tree --write-tree`, git 2.38+). A conflicted merge still exits 2, and the hub is not touched. Only a clean merge is made in place. An older git merges in place and aborts, as before. - A queue cursor tells a rollback from a purge. When its watermark is gone but the file holds blocks not newer than it (an id not above its id, a time not after its time), it resumes after the last of them, not at 0. When the newer version comes back, the watermark is ahead of the cursor and everything through its block is skipped. Both rules need a header with an id; without one, nothing changes. - A node that runs a copy of `mesh-sync.sh` rather than the one in the package gets the first fix only when the copy is replaced. ## 0.9.38 — 2026-10-04 - **Nine rules from a finished project, in the role rules where they are read.** Each comes from a case that happened. Heads: the cheap mechanical check of an artifact is the first dispatch and is rerun after every step; a full pass waits until names are fixed; verification gets no less time than production. Every role: exit code 0 is not a result; a claim about quality is a sample; check against the original, not a retelling; an event is a `FACT` only once observed or confirmed; an owner decision outranks an older practice found in the history. The orchestrator does not warn again about a risk the owner has weighed. The protocol's compaction section: a label in the narrative ("a project convention") is checked against state. The rules extend existing lines; a render grows by 0.9-1.2 KB. - **A note for authors is not rendered in a fragment either.** It was stripped only in the top template. None of the nine rules has a check, so each is marked `` next to it; the role never reads the mark, and `grep -rn '^` line. A missing or blank variable is an error with its name, never an empty string: a blank in a role's rules drops a fact the role cannot know it lost. A variable used but not declared, or declared and never used, is an error with file and line. `--check` exits 1 with a line diff when the file differs from the render, and every render error exits 2, so a loop can tell stale rules from a broken template. The command only reads, like `harvest`. Format: `prompts/meta/README.md`. ## 0.9.31 — 2026-10-01 Fixes from the acceptance review of 0.9.30. - **A command that only reads writes nothing.** Every `hub` run refreshed the protocol first, and that wrote `HUBD.md`, created the hub's folders, appended lines to the tracked `.gitignore` and removed every `.tmp` file older than a minute, so a dry `hub gc --json` deleted the very stale file it was meant to list. Reading commands (`status`, `brief`, `board`, `doctor`, `task list`, a dry `gc`, ...) now leave the hub byte for byte as they found it, task cache included; the hub's folders are created by the first write. A test snapshots a hub around each of them. - **No command appends to the tracked `.gitignore` any more.** In a mesh that file is a commit, and two nodes on different versions appending different lines to its end stopped each other's sync on a conflict. The node-local lines (now also `*.lock`, `*.tmp.*`, `tasks.json`, `claims.json`) go into `.git/info/exclude`, which never travels, on every writing command; `hub init` completes the shared `.gitignore`, once, on one node. `hub doctor` warns about a node-local file nothing ignores, and notes lines only the exclude file has. - **A node is live by its own files.** Which nodes still write to the mesh decides whose queue files gc may move, and it was read from presence snapshots and commit names, while mesh-sync commits under the hostname: a node whose `HUBD_NODE` differed and whose snapshot was stale read as gone. The newest stamp in its journal, its task log or a queue file of its name counts now, and `mesh-sync.sh` commits under `HUBD_NODE` when set, normalised as the file names are. - **The board's journal window skips old month archives.** It read and parsed every journal file since the hub began and filtered to its window afterwards; a month archive that ended before the window is no longer opened. - **`hub sense`:** an escalation that cannot be written is reported on stderr and in the turn text instead of being dropped, and a missing `HUBD_SENSE_ESCALATIONS` directory is created; a private pattern that is not a valid regex no longer counts as declared for `hub lint`. - **Harvest:** every project card ends with a Communication section, what has gone out and what is still queued. ## 0.9.30 — 2026-09-30 Fixes from the acceptance review of 0.9.28–0.9.29. - **The board escapes quotes.** Its `esc()` escaped `<`, `>` and `&` only, and a heartbeat status (free text any loop writes) went into a `title="..."` attribute: a status with a quote in it opened an event handler in the owner's browser. Both quotes are escaped now, and every value the page renders goes through it. A role alive without state fields says how long ago it was seen; "+ other projects" shows every project with open work next to the tracks (`/api/board?all=1`); the board reads a journal window (30 days, or `--days` if longer) instead of the whole journal on every refresh. - **`hub gc` is a dry run without `--apply`, all of it.** The old half of the command removed stale locks, cache backups, `.tmp` files, idle readers and session records on every run and printed them ahead of the listing, so `hub gc --json` was not JSON. That litter is now one more class, listed and removed only by `hub gc --apply --by `. - **A node archives only queue files it may move.** Its own shards, and shards with no writer left (a node with no presence snapshot and no mesh commit for 30 days; a file with no node in its name) — never another live node's shard, whose writer was left with a modify/delete conflict and a stopped sync, and never an empty file. `hub gc` and `hub queue gc` list what they left and why. Whether a role is alive now counts every node's published presence, not only this node's. - **The hub's `.gitignore` is completed, not only created.** `hub init` wrote the node-local lines only into a new file, so an older hub never got `.mesh-freeze`: a commit by hand carried a freeze to a peer. Every hub run now appends the lines an existing `.gitignore` lacks, `hub freeze` warns when its marker is tracked anyway, and `hub doctor` reports node-local files git tracks. - **`hub sense`:** a wake held back by the hourly budget restores the minute's fingerprints along with the journal marks (the held-back report used to count as seen and never reached the head); with no private pattern declared a branch fails the checklist instead of passing it; a head may carry its own patterns as its `private` attribute; escalations go to `HUBD_SENSE_ESCALATIONS` when set (a parallel run keeps its own file by default); a task `in_progress`, `doing` or `active` counts as open work; the published `sense..json` is written under a lock, so two heads finishing together no longer lose one update. - **`hub` that cannot load exits 3 for `hub sense`.** A module that fails to load is thrown before any of the CLI runs, and Node exits 1 — which a loop reads as "no events". The installed `hub` is now a small wrapper that catches it, prints it, and exits 3 for `hub sense` (other commands keep 1). - **`hub lint` / `hub audit`:** a head that accepts branches with no private patterns declared; one role alive on two nodes at once (each node has its own cursors, so both carry out every order) — in the audit too, with a stable key. ## 0.9.29 — 2026-09-30 - **`hub sense` does not re-raise a branch the journal already judged.** Its own state was the only memory of past verdicts, so a fresh state — a new node, the first pass of a run in parallel with another sensor — raised every open task branch as critical, including ones rejected hours ago. A `decision` entry `ACCEPT|REJECT ` for that branch and sha now counts as decided. - The sensor's state directory and escalation log keep the group bits on a shared hub, so a head's loop and an operator running as different users can both write them. ## 0.9.28 — 2026-09-30 A fleet asked for four things that all needed the same missing piece — the hub knew every role only by what it wrote, never what it IS — so that piece comes first. - **Roles are cards.** A role is a resource card of type `role`: `rank` (`head`, `worker`, `fleet`), `project`, a `head` link from a worker, `status: off`, and any attribute its tooling needs (`repo`, `base`, `review`, `plan`, `idle_min`). `hub resource set` takes open attributes with `--attr key=value` (`attrs` over MCP); an empty value removes one. `hub lint` reports open work assigned to a name that is neither a role nor an owner role, and says it checked nothing while no roles are declared. - **A loop reports its state as fields.** `hub heartbeat` / `hub_heartbeat` take `state` (`turn` | `waiting` | `exit`), `turn`, `turn_started` (`now`), `empty_count`, `silent_count`, `exit_reason`. A supervisor used to parse the loop's status wording and went blind whenever a word changed. The hub keeps `state_since` across heartbeats that repeat a state — the loop's own counters restart with the loop — and a turn's start while its number stays the same. A heartbeat without them keeps the old record shape. - **`hub board`, and a Tracks view in `hub serve`.** Every track (a project with a head) on one screen: each role's state, current task, last journal step and handoff; what got done in the window with the journal line that accepted it; what is next, most urgent first; what is blocked and on what; the branch verdicts; what the head's sensor last raised. Above them, what waits for the owner: the owner queue, the owner's tasks, `owner-go` tasks, escalations to a `fleet` role and its answers. Titles are a task's first line, at most 80 characters, on the kanban too; the rest is one click away. Tracks are the default view when heads are declared. A tenant's board reads only the tenant's queues. - **The History view** of `hub serve` is a bar per month of the journal and one entry at a time, with step and playback controls; it reads the journal through the same reader as every other view. - **Work mode: a role's queue as its tasks.** `hub queue wait --tasks` (`hub_queue_wait` with `tasks: true`) also returns the role's open, ready tasks, most urgent first, each with its claim and the latest messages that name it, and wakes while one is offered. Reading consumes nothing; starting is a claim on the task (`hub claim --task `, `hub_claim({task})`) with a TTL, and a lapsed claim offers it again — so a turn that did nothing with an order loses nothing. An order about a task that was closed meanwhile is held back: cancelling is closing. `hub queue work ` shows the view without waiting. Without the flag nothing changes. - **`hub sense `: a head's sensor without a model.** Workers judged from heartbeat fields (idle on the sensor's clock, stuck, a long turn; a worker that is not running goes to the fleet, not the head), new reports from the journal — to the minute with a fingerprint, so two reports of one minute are two, and never one the worker already sent to the head's queue, matched by text — and task branches in the head's repo through a checklist in its review clone (on the base, commits, build products, private patterns). Exit 0 with text wakes the head, 1 is nothing new, 3 is a failure. Non-critical wakes are budgeted per hour, an event standing an hour is escalated. `hub sense verdict accept|reject` writes `ACCEPT|REJECT ` to the journal and sends the order to whoever handed the branch in. Thresholds and patterns live in `/sense.json`, state in `.sense/` (node-local), and each node publishes `sense..json`. - **`hub gc` lists what piles up, by class, and archives it the one way the mesh accepts.** Queues of a name that is no live role, dead waiter markers, stale presence of non-roles, notices whose cause is gone, and — listed only — open tasks on such names. `--apply --by ` moves queues into `queues/archive/` with their cursors, in one commit. **mesh-sync now accepts a deleted log exactly when the same bytes are in an archive**; before, it refused the very `hub queue gc --apply` its own refusal message recommended. An `rm` or an edited copy is still refused. - `hub lint` reports two live readers on one work queue while it is true. - **Fixed: the owner's list of queue items was empty for every block written since block ids exist.** Its header pattern did not allow the id between sender and task, so `hub brief` counted buttons it could not list. - **Queue messages carry a block id and can be acknowledged.** Headers read `· from · id `, and `hub_queue_ack` marks a block processed, not just read, so a sender can tell a zombie reader from a working one. - `hub_report` takes `TO: `: the entry stays public, and readers can filter by addressee. - `hub lint` reports two cards that describe one project (same path or repo); `hub cards merge ` aliases one to the other and archives it. - Unknown CLI flags are an error instead of being ignored. Interrupted atomic writes clean up after themselves, and stale temp files are removed at start-up and by `hub gc`. - `HUBD_TASK_ID_PREFIX` replaces the hostname in new task ids; the sequence continues from the highest id under either name. - A cursor-conflict notice clears once a work queue is back to one waiter. ## 0.9.27 — 2026-09-27 - **`hub presence --json` and `hub log --json`.** The table render truncates names and statuses to a fixed width and the journal line drops fields, so scripts were parsing the text or reading `presence/*.json` behind the hub — re-implementing the cross-node merge as they went. `--json` prints the same object `hub_presence` returns and the journal entries as stored. - Orchestrator prompt: an empty queue is a dispatch cycle, and a fleet blocker is re-measured before a lane waits on it. ## 0.9.26 — 2026-09-23 - **A broadcast reader keeps its place across a respawn** (task maple-99). The cursor of a subscriber was keyed by the client process, so every restart of a role got a fresh namespace: it either re-read the whole queue or, with `--from-now`, skipped what arrived while it was down — and each dead namespace stayed in `hub queue status` as a reader "behind" forever (eight for one role on one node). Cursors now key on a name that survives the process: `HUBD_SUBSCRIBER`, else `HUBD_SESSION`, else `HUBD_AGENT`, else the process as before. The author floor and the whatsnew checkpoint keep the per-process id — `HUBD_AGENT` is shared by every session on a machine, and an author shared that way is what the floor exists to prevent. Two live sessions that do share a reader name on one broadcast role are now detected and reported (they would split it), with the fix. The first wait under the new name starts a new cursor once, like any new subscriber. - **Dead reader namespaces are archived, not kept forever or deleted.** `hub queue gc` lists namespaces idle for 7 days (a live reader rewrites its marker every poll) and `--apply` moves them to `.qstate/_archive/`; `hub gc`, which used to `rm -rf` them, now moves them too. `hub doctor` names them. Archived namespaces are not counted as readers anywhere. - **A card no longer grows a second copy of a section** (task maple-112). Writers matched one exact heading, so a card written before the hub was localised — or a heading typed in another case — got a new section beside the old one, and every later copy of a heading was dead: nothing could append to it, readers still saw it. Writes now find the section a card already has for that key under its configured heading, the English default, or a declared alias (`"aliases": [...]` in `sections.json`), case-insensitively. `hub doctor` names cards holding a section twice, and `hub cards merge-sections` (dry run by default) folds them: lists concatenate in file order into the live section, and a second next step goes to `projects/history/`, because two current steps is the defect. A heading that merely resembles a key (`## Facts` beside the localised facts section, a hand-written section on the hubs it appears on) is left alone unless declared an alias — merging it would let rotation move curated facts to history. - **The author rule now covers claims, heartbeats, freeze, absorb and `cards compact`.** A claim or presence record under `claude` is as unattributable as a journal line under it. - **A stale lock is stolen by rename, not unlink.** Two waiters that both saw a stale lock could have the second remove the first's fresh one, leaving two holders; the steal now moves exactly one file and puts it back if it turns out to be fresh. - **The secret store check resolves symlinks.** A store reached through a symlink into the team root passed as "outside" while every byte landed in the replicated tree. ## 0.9.25 — 2026-09-23 An audit pass over the whole codebase: every finding below was reproduced on the code before it was fixed, and each has a test that fails on 0.9.24. - **Security: a queue role could name a path.** `hub_queue_send` used the role verbatim in `queues/..queue.md`, so `role: "../../x"` wrote `x..queue.md` two directories above the hub — over the HTTP transport, anywhere the server user can write, from any tenant. Role and subscriber names are now checked before any disk access (letters, digits, `-`, `_`; subscribers may also carry `.`), and refused rather than rewritten. - **Security: over HTTP, a caller's `cwd` was resolved against the server's disk.** `hub_context` looked for a `.hubd` marker and a git root under whatever path it was given and, through `claimsTouched`, listed recently modified files there; `hub_claim_check` and `hub_presence` did the same, and the audit riding on `hub_brief` ran `git log` in a card's `- path:`, which a tenant writes. The HTTP transport now sets `local: false` on every call (a caller cannot override it), and those tools answer from hub data only, saying what they did not check. - **Security: the multi-tenant board showed the operator's `AGENTS.md`** to every tenant that had not written its own, through the team-root fallback. A tenant now sees its own rules or none. - **`hub whereami` ran the marker's inventory script through a shell**, quoted with JSON double quotes, inside which a shell still expands `$(...)`. It is executed directly now. - **`hub_section_add` was the one card write with no size cap.** 0.9.23 rotated over-long sections on report, sync and card-set; a section fed only through here grew without bound. It rotates too. - **History files in a shared hub were created owner-only.** `projects/history/.md` is appended by whoever writes the card; the first writer left it `rw-r--r--`, and the next user's card write then failed on the history step before saving the card — the maple-98 class, in one more place. All history appends (and absorbed files) now go through the same group-bit rule. - **`DECIDE: a | b | c` lost `c`** from both the card and the journal: the split kept two parts. - **A claim written without `ttlMin` crashed the next conflicting claim** after saving it (`Invalid Date`), and printed `NaNm left` in `hub brief`. - **`hub_get`'s truncation note read `card: undefined shown, undefined hidden`** when the card string was cut; it now says how many characters were shown and hidden. - `hub doctor`'s torn-write line described a 200-line window the check no longer uses. - Refactors, behaviour unchanged except that a task with no `created` stamp now sorts the same way in `hub_brief` and the kanban as it already did in `hub_next`: one urgency sort instead of three copies, one reader for the shipped protocol, one digest-size refusal, one tail reader and role-file matcher in the queue module, and the node-name rules shared between the mesh check and the log readers. ## 0.9.24 — 2026-09-23 - **The queue depth a send reports now knows what it can mean on this node.** 0.9.21 added the depth so a sender could see a backlog instead of trusting "sent"; it read that depth against a cursor that is node-local by design, and then said "nothing is consuming" about it. For a role consumed on another machine — most sends in a fleet — that accusation is simply wrong, and caught it on its first real use: a send to an orchestrator read as 172 unconsumed messages while its consumer was working normally one node away. A role this node really does consume still gets the warning; a role it has never consumed gets the fact instead — this is the only view this machine can have, check on the node that runs it. The reply carries `consumedHere` so the distinction is machine readable, not only in prose. ## 0.9.23 — 2026-09-23 - **A card is a snapshot, and hubd now holds it to that.** "3-6 lines of current state" was in `hub_card_set`'s own description and held for nobody: one hub reached 41 cards with three past 72 KB and one past 250 KB, and `hub_get` on the largest returned 72444 characters that the caller's context refused — the tool could not deliver its own data (task maple-111). The growth was not where it looked: every digest was fine at 1.7-3.6 KB, and the bulk was `## Facts & hypotheses` at 47, 63 and 231 KB, written one `- fact:` line at a time by `hub_report` with nothing to rotate it, so a cap on the digest alone would have changed nothing. Now: an accumulating section that outgrows `card.sectionBytes` has its OLDEST entries moved to `projects/history/.md`, and the card keeps the recent ones plus a line saying where the rest went. Moved, never trimmed — `DECIDE:` is journaled but `FACT:`, `HYPO:` and `COMM:` are not, so the card is their only copy and dropping the tail would destroy it. Enforced on WRITE, because a card allowed to grow on one node arrives over-sized on every peer; a card holding conflict markers is left alone, since resolving it is a human's call. A digest over `card.digestBytes` is refused, and so is an `appendLine` that starts with a date — that is an event, and the refusal names `hub_report`, which writes the card section and the journal both. Limits are the hub's, in `limits.json`, not constants in the code. - **`hub_get` can no longer become unreadable.** The output budget only ever shrank arrays, and a card is one string field — so the largest payload in the answer was the one thing it could not cut. It now trims long strings too, from the head (where the digest and the next step are), and reports `shownChars` / `hiddenChars` with how to read the rest. The 257 KB card that started this returns an 18 KB reply. - **`hub cards compact`** brings a hub that grew before the limits existed back under them. Dry by default: which section of which card loses how much, and where it goes. On the hub it was written for, six cards went from 257/86/72 KB to 22/22/32 KB with every line accounted for. ## 0.9.22 — 2026-09-14 - **`hub doctor` names the peers that have stopped appearing in the mesh.** Found by running the 0.9.21 checks across the fleet: one node had been 77 commits behind for hours because a single card conflict aborted every pull. Its own doctor said so precisely — and nobody runs another machine's doctor. From any other node the evidence was already in the shared history and unread: mesh-sync commits as the node it runs on, so a peer that stops pushing stops appearing. Only real participants are judged (a node that never committed here is an absorbed log or a legacy name, not a machine gone quiet), and only while the mesh itself is moving, so a week nobody worked does not light up every row. Hostname case is matched across the two spellings mesh-sync and the file names use. ## 0.9.21 — 2026-09-14 - **A broadcast role no longer reports a "pending" that is true for nobody.** Every reader of a fan-out role keeps its own cursor, so the shared one is never advanced and the arithmetic against it only climbs. `hub queue status` printed that number as the headline, and it was quoted as evidence of a delivery failure — about a project head that had been working all day, while the real stall was on another node entirely (task maple-98). `hub_brief` had refused to print it since 0.9.5; the ledger now agrees: readers with how far each is behind, the shared-cursor arithmetic kept as `sharedCursorPending` for forensics only. ## 0.9.20 — 2026-09-14 One theme: a hub that cannot do its job must say so, instead of looking idle. - **A queue cursor that cannot be written no longer reads as an empty queue.** Measured across four live fleet roles: 12, 27 and 43 KB of orders undelivered for a day, while every wait answered NO_CHANGES, every send answered "delivered", and every role logged "queue empty" (task maple-98). Delivery advances a per-file byte cursor; on a shared hub a command run by another user leaves that cursor owned by them, and `drainFile` caught the resulting EACCES the same way it caught a busy lock — the one error where "skip this poll, retry next" is right. Now only a contended lock is transient: anything else raises `QueueStalled`, naming the file, what it costs and the command that fixes it. `queueWait` checks writability BEFORE it blocks, so the stall is reported while the queue is still empty rather than discovered by the message that gets lost; one broken cursor no longer hides the roles that are fine (they deliver, with `stalled` alongside); `hub doctor` lists such cursors first among the queue checks; and `hub_queue_send` returns the depth now waiting, so a sender sees a backlog that is climbing instead of trusting "sent". The request in the task — drain the backlog before entering the long-poll — was already the behaviour since the first release; it was never the cause. - **In a shared hub, a file hubd creates is writable by the group that shares the directory.** This is the same failure at its source. The node that proved it ran the role as `freebsd` while the cursor had been created by `agent` with the default umask — and neither user could repair it, because `chmod` requires ownership. Now every file hubd writes into a group-writable directory (queue files, cursors, waiter markers, the journal, task events, usage, anything through `atomicWrite`) gets group read/write. A directory without group write is a private hub and is left exactly as the umask made it, which is every single-user install. - **A waiter marker whose process is gone is cleared.** The `finally` that removes it only runs on a clean exit, so every killed session left one behind; six were found on two nodes, each making the next waiter report a conflict that did not exist. - **`hub freeze "" --by ` / `hub unfreeze`.** Every dangerous operation on a hub directory starts with "stop the sync first", which meant remembering which of launchd, cron or a systemd timer this node uses, under time pressure — twice it was not remembered. The marker is node-local and gitignored (a mesh-wide freeze would have to travel by the sync it just stopped). `hub doctor` states the freeze, and calls it a warning after six hours, because a freeze somebody forgot is a node that silently stopped syncing. - **mesh-sync keeps a shared hub writable, and refuses a deleted log.** On a fleet node the sync runs as root and the roles run as someone else, so every directory a pull creates locks them out — the source of the stall above. After any pull that changed something, group write is restored over a hub that is itself group-writable; a private hub is untouched. And the append-only guard now covers deletion: it read diffs, and a file that is gone has no diff, so removing `journal..jsonl` or a queue file passed straight through to every peer (exit 4, naming `hub queue gc --apply` as the way to retire a queue properly). - **A heartbeat records which hub it was written into**, resolved through symlinks. Two roles on one machine writing into two hubs was invisible for a day, twice (tasks maple-88, -96), and was found by a human comparing directories. `hub presence` now flags an agent on this node whose records go elsewhere; records from other nodes are not flagged, since every node legitimately has its own path. `hub doctor` and `hub_whatsnew` also report a hub directory this process cannot write — the condition under which everything reads healthy and nothing you write is kept. ## 0.9.19 — 2026-09-12 - **`hub absorb` is all-or-nothing.** Its first field run stopped half-way with `EACCES`: the `absorbed/` directory had arrived on that node through a root `git pull`, without group write, so the logs were already on disk when the queue copies failed — and the label then counted as used while the manifest and the queue history were missing. Every directory to be written is now probed for write access first, the logs are written last, and a failure removes everything the run created before surfacing, so the same label works again once the permission is fixed. The error says what to fix. ## 0.9.18 — 2026-09-12 - **`hub absorb

--as