Describe agents and spaces, update the README to be more user-friendly - #1
Merged
Conversation
…tool, and the build context sheds the diary - .dockerignore: .claude/, .byre-devlog/, coverage/ no longer ship to the build daemon - http-api.md: GET /spaces admin shape matches what listSpaceSummaries returns; the /stream row admits the limit param the validator accepts - ui/api/types.ts: the lastSender doc sits on lastSender again, not on SpaceSummary - changes.tsx: the docblock claims only what the signal carries (writes), naming the read-derived surfaces it does not - @vitest/coverage-v8 + test:coverage: the 21 c8 pragmas now address a tool that exists (lines 86.22%)
…, ids on every row, and a full-body read command The official client showed 140 characters of every message, printed no handles, and advanced the cursor past what it never displayed. Rows now carry conversation and message ids in fixed early columns (free text is tab-flattened so it cannot shift them), previews clip at 400 with a … only when truncated, and 'read CONV_ID [N] [MSG_ID...]' prints full bodies verbatim — filtered client-side, since there is deliberately no per-message endpoint. onboard now says what it does (anchors the cursor) instead of claiming to watch, and the unscoped-cursor shim stops attributing its own version.
…t never could readConversationAsOf now answers 'what could this agent have seen': a conversation in a space the agent held no membership in at the read's recorded tip (or, for a tipless legacy row, at its millisecond) comes back not-found instead of rendered under the agent's name. A recorded tip of 0 is a read taken before the agent belonged to anything, so it now honestly shows nothing at all. renderAsOfRead — the single-message renderer with no tip cutoff and no callers — is deleted, route, docs row, and tests included. The README's 'exactly what it had been served' weakens to the contract the code keeps, and ADR-0004 states the one accepted bound: the human's display name renders as it is now.
…tself while watched Search re-runs a live query when the signal fires (never the draft, never past the first page), the roster follows grants, revokes and key changes (last-seen and failure counters deliberately stay off the signal), and the read log — which no write ever signals — polls its own newest page every five seconds while the tab is in front, folding new rows in by id with a pure merge that declines gaps rather than leaving holes. The rename dialog now names the fork it can cause, and the as-of banner admits the one label that renders as-now.
The page-a-human channel gains its missing half: acknowledged_at (migration 0005, a plain ADD COLUMN), an idempotent POST /escalations/:id/ack, and a headline count of the unacknowledged — a number the human can drive to zero — with delivery state demoted to per-row detail and hidden entirely when no webhook is configured, where nothing was ever going to be sent. The webhook's catch now keeps the real reason (status, timeout, DNS) on every failed attempt, and give-up records the cause instead of replacing it. The README's docker example mentions DOGPARK_WEBHOOK_URL, and running.md says plainly that a hidden tab does not poll — the webhook is the out-of-band story.
… it has none The look book's floor was 'React emitted a character': 84 of 92 stories asserted nothing beyond non-empty HTML. The render test now refuses a story that neither supplies expectText nor declares expectText: null, and the backfill drew each expectation from the story's own fixtures — bodies, names, counts, error details — so 91 stories now prove their data rendered and exactly one (an empty-markdown state with nothing to say) opts out, visibly.
…the server, the tests, and the UI src/types.ts held a 391-line interface nothing implemented while the real contract lived in three unchecked copies — request zod, an ASCII shapes block, and hand-copied UI types behind as-casts. It is now the single statement: schemas for every request and response body, types inferred from them, ids branded at the type layer only. shapes.ts returns real types (which surfaced two nullable lookups the wire requires present — now asserted on their FK invariants), the smoke tests parse live responses through the schemas in place of the ASCII block, and the UI re-exports the inferred types and threads schema values through one decode(): parse in dev, cast in prod. The dead exports are gone, the unused GET /agents/:id/keys route with them (shown red first), and the README's 'the protocol is stated in src/types.ts' is now literally true. Bundle price, accepted knowingly: +24.5 kB gzip — schemas are values, so rollup keeps them.
…imits and the reconstruction keeps its clock straight A poll that outlives its filter discards itself (generation counter); a re-fetched row replaces the held copy a compaction moved on, and the merge now says gap, unchanged or merged out loud so the screen can refresh honestly at the tip — on a gap, past the 300-row cap, or over a stuck Failure — while a reader paged into history is left alone. The as-of Reader stops framing a refusal as success: a not-found under asOf gets copy that does not overclaim which of the two hidden reasons applies. membershipAtTime now shares the message path's inclusive millisecond (a revoke in the read's own millisecond no longer hides what a same-millisecond message would show), the client stops promising that N above the server's 1000-row cap reaches further back, and withKeys/GET /spaces lose the last unknown seams in the typed contract.
…omes from the page, not a constant A poll out across a refresh was a stale snapshot allowed to land on the fresh first page; now refresh invalidates it. cmd_read's not-found advice reads got/hasMore off the returned page itself: whole thread in hand means no such message, a short page with more behind it names the server's real cap, and only a full page still says raise N.
… shows no thread at all The fresh blinded review caught what the resumed loops could not: a compaction sweep deletes rows, so older ones slide up onto the newest page, and the merge was crowning them the tip — now an unheld id behind a held one is a gap, settled by refresh (shown red first). And the Reader's as-of refusal was honest copy over dishonest content: loaded messages from another view of the same thread kept rendering beneath it (Back to now, then browser Back). A refusal now empties the pane it speaks for.
… the first page moved under it Keying Thread on conversation alone let live and as-of share state: the other view's messages flashed under the as-of banner while its fetch was out, and stuck there on any failure that was not the gated not_found. The view is now part of the key. In the read log, a replacement landing in the first-page suffix — the tip row mutated by compaction, the common case — was sliced off and written nowhere; the poll now reloads honestly at the tip when the suffix moved. And a held row is replaced when its resolved label changed too (shown red first), not only when a sweep moved its counts.
… descriptions, served on the listing surfaces only Three kinds of operator-authored orientation text — a space description, an agent description, and a note on why an agent is in a particular space — land as one append-only table with seq from the shared sequence, so the value at any moment is derivable and an edit never destroys its predecessor. They are served on /identity and /agents (and the admin lists), never on a message page or the stream: those listing reads are not read-logged, so the reconstruction contract is untouched, on the same footing as display names. Membership notes key on the (agent, space) relationship rather than the interval, surviving revoke and re-grant. Plain text, whitespace-normalized, capped by the advertised maxDescriptionChars limit.
… announce a change Description text lands on the spaces list, the space screen (with each member's note and last-seen) and the agents list, each with an edit affordance opening one dialog parametrized by kind. The dialog offers "announce this change": a prefilled, editable human message posted to an Announcements thread in the affected space — for an agent's description, a checkbox list of its current spaces. The description save stands even if an announcement fails; the failure surfaces as a toast. Per-space notes are not shown on the agents list: the roster response does not carry memberships and deriving them is one request per space. They live on the space screen, next to the membership they describe.
…age that matters: append-only annotations, sticky completion One journaled table carries completed/reopened/pinned/unpinned rows with seq from the shared sequence, so status and the pin set are derivable at any tip — the as-of thread view gets annotation state for free, actors rendered under the labels in force then. Completion is a quieting signal, not an outcome: posting into a complete thread does not reopen it, the post response says so, and reopen is explicit. Pins are one per actor per conversation; pinning moves your own, and nothing moves another's. Rights are the posting right: the human, or any agent with an open membership. complete:true and pin:true ride postMessage inside the posting transaction; bare actions exist for both sides. State changes wake the human's /changes poll and never enter the agent stream.
A worktree setup step created it and a blanket add swept it into the previous commit. It is vite's cache location, gitignored, and never meant to be tracked.
…omplete, reopen and pin The thread header carries a complete chip and a Complete/Reopen action; pinned messages are marked with who pinned them, stacking convergent pins on one message; a Pinned summary at the top quotes each pinned message and jumps to it; each message offers Pin (move the human's one pin here) or Unpin. The composer gains "mark complete" and "pin this message" flags, and posting into a complete thread without the flag shows a quiet notice with a Reopen link — completion is sticky, and the UI says so rather than letting the thread look reopened. The as-of view shows the state at that read and offers no actions. Thread listings show a complete pill and a pin count.
…t happened since I last looked A mutable, forward-only mark per conversation — the one deliberate exception among today's tables, because nothing rendered to agents ever depends on it and history would buy no evidence. It lives beside, not in, the agents' read log (ADR-0005): a convenience cursor is not a forensic row. The catch-up listing returns every conversation with messages past its mark, newest activity first, with unread count, last speaker, completion status and whether pins exist; a completed thread appears only while it has unread messages, which is the counterweight to sticky completion. Space summaries gain an unread count from the same marks.
…or a self-contained zip Human-only, admin-session reads that never add read-log rows. Markdown renders through the existing path, so mentions carry current names (ADR-0014); the status line carries completion and pins with their actors; a space export opens with the space description. JSON is the protocol's own wire shapes. The bundle is a streamed zip of both documents plus every attachment's bytes under attachments/<id>/<sanitized basename> — the id is the path authority, a filename never is — with relative links so the unzipped folder reads offline. Missing bytes are called out, not fatal.
…export a click away A new default screen lists conversations with activity past the human's mark, newest first — space, title, unread count, last speaker, a "complete, N new" pill on completed threads still murmuring, a pin glyph — with unacknowledged escalations on top and "You're caught up" when empty. Opening a row lands on the first unread message, loading enough history to reach it. The spaces list shows unread badges. Thread and space headers gain an Export menu offering markdown, JSON, and the zip bundle as plain cookie-authenticated download links.
…thread view Marks were advanced only on arrival from a catch-up row, which carried the tip seq in the URL; a thread opened from the space reader never advanced its mark and stayed "unread" in catch-up indefinitely. Messages carry no seq on the wire, so the Reader now names the newest message it displayed and the server resolves the seq, refusing a message from another thread. The as-of view still marks nothing: it shows what an agent saw, not what the human is reading.
…a stale poll cannot revert an action, and JSON export streams A read-log row from before tip_seq was recorded has no seq to cut annotations at, and the fallback was no cutoff at all — a completion made months after the read showed in the forensic view. Annotations now fall back to the same millisecond ceiling the messages use, so the two views agree on the instant. In the Reader, annotations arrive from a load, a poll, and an action's response with nothing ordering them; a poll that began before Complete was clicked could land afterwards and un-complete the thread on screen. An action's response now bumps an epoch that older loads and polls check, and polls carry a serial so two under one epoch cannot land out of order. The bare JSON export was the one format still built in memory and parsed twice; it streams like the others, and the route test parses the response against ExportDocumentSchema so the schema stays load-bearing.
…d a replay tells the truth Two annotation actions in flight — Pin A, then Pin B — could land in reverse and leave A on screen while the server held B. Each action now takes a serial when it begins and only the latest-begun action paints its answer; an earlier one's late answer still outranks any poll. The bundle walked each conversation three times as separate paged reads, so a post landing mid-export could put a message in the .json the .md never saw. An export now takes one snapshot — an exclusive upper bound from the app clock and the annotations, once — and every walk reads under it. Zip attachment entries are added by path, so yazl opens each file when the archive reaches it instead of the export holding a descriptor per entry. A replayed complete/pin key applies nothing and answers the current state, which after a reopen is 'open'. That was already the behaviour; the API docs and the agent guide now say it is the state, not a failure, and not grounds to complete again.
…notation answers join the action ordering The snapshot bound was the clock, exclusive: a message committed earlier in the same millisecond the export began was dropped, and adding a millisecond would have admitted writes landing during the export. The bound is now the stream tip when the export begins — readConversation takes an inclusive seq ceiling for snapshot callers, and the annotations are taken as of the same seq — so nothing the clock cannot separate is misplaced. Proven with time frozen. A post that completes or pins, and the composer's inline Reopen, change annotations like the thread's buttons do but their answers skipped the serial; a post-with-pin answered after a later standalone pin repainted the older pin. The composer now takes a serial when its action begins and hands it back with the answer.
…t began Messages were bounded by the snapshot's tip but rendered under current labels page by page, so a rename landing mid-export could split one document — pages, or the .md against the .json — against itself. The snapshot is now a position in both of the store's orderings, the stream tip and the label-history seq, the same pair a read-log row records; a snapshot read renders and reconstructs annotations under it, as the as-of view does. Label history has its own counter, which is why the tip alone could not stand in for it.
…o docs catch up with the code The poll's gap heuristic — no overlap with what is held means more than a page arrived, so leave it for Refresh — also fired when nothing was held, so a thread that loaded empty never received its first messages by poll. Nothing held means the newest page is simply the first page. architecture.md still listed the absence of unread state as an open question; the export paragraph in http-api.md still called the pin state current when it is taken as of the snapshot position.
… history along Round five appended the first messages onto an empty thread but kept the empty load's cursor and hasMore, so when more than a page had arrived the older messages were unreachable until a Refresh. Nothing held means the polled page is the thread's first page, paging metadata included.
… markdown never lets a name become structure The composer's Reopen link had no rejection handler, so a failed call was an unhandled rejection and the notice stayed put; it now toasts like send(). The export placed filenames, sender names and titles straight into the markdown it generates — a filename such as report](evil.md became a link. Everything that lands inside generated structure is escaped; message bodies are untouched, because they are markdown by design.
Escaping inline punctuation left block structure reachable: a title carrying a newline could start a list item or a fence on the next line of the generated markdown. Block constructs need a line start, so generated labels collapse whitespace runs to one space before the inline escapes; strikethrough tildes join the escaped set. The bundle test picked attachments[0] as notes.txt, which with two uploads is a coin flip on id order; it now picks by name.
… post flag takes a fresh idempotency key Choosing which answer to paint by client intent could not fix the action race: two in-flight requests may be handled by the server in the other order, leaving the screen on B while the server finished on A. Actions now queue — the second is sent only when the first has answered — so every answer is the server's state after that action and the last is its last word. The composer's posts and inline Reopen join the queue; a failed action does not block the ones behind it. The composer cleared its draft key when the body, title or files changed but not when the complete/pin flags did; the flags are part of the hashed request, so a retry with a flag flipped was refused as a different request under a used key and stayed stuck.
…n the next full load The mark was recorded as sent before the request went out, so a failure was shown once and then forgotten: Refresh cleared the error without retrying, and the thread sat in catch-up as unread until a newer message arrived. A failure now forgets the attempt, and a full load asks again.
A post may now wait its turn behind another annotation action, and the draft stayed editable while it waited; when the old submission landed it cleared the fields, taking the newer edits with it. Every draft control — title, body, attachments, the complete and pin flags — is disabled while the post is in flight.
…replay under a printed key complete, reopen, pin, unpin and escalate minted an idempotency key internally and never showed it, so an ambiguous failure — sent, answer lost — could only be retried under a fresh key, re-applying the action: a completion re-done after someone reopened, an escalation paging twice. They now accept --idempotency-key, print the key before the request, and on failure name the exact replay, as post and reply already did.
…ttempt When a newer tip's mark was already in flight, an older mark failing cleared the flag for both, so the next full load marked the newest message again. The failure now forgets the attempt only if the flag still names it.
…export always opens with its escaped description, and the client shows the server's reason The annotation store had its own writerOf without the refusal posts make: a hand-written agent row named ':human' could reach the human's idempotency namespace through complete or pin. It refuses now, as posts do. The space description was interpolated into the export unescaped — plain operator text that, on its own line, could open a heading, list or fence — and the space header carrying it was emitted only when the space held more than one conversation, because the format keyed on conversation count rather than on what was asked for. The source now carries its kind; a space of one thread is still a space export, description first, escaped. The client's annotation and escalate commands swallowed the server's error body on failure, leaving only the replay hint; the body is shown first.
… confirmed, and the client reports an escalation's real status The Reader's complete, reopen, pin and unpin sent no idempotency key. The action queue orders requests within a page; it cannot protect one whose commit succeeded but whose answer was lost, and a second click after such a failure would apply the action again — a completion re-done after someone reopened. Each attempt now mints a key and keeps it until the attempt is confirmed; the next click on the same control replays under it, and a replay answers the state now, shown rather than overridden. The composer's inline Reopen keeps its key the same way. Round fifteen captured api's output in a command substitution, which runs it in a subshell and loses LAST_HTTP: a successful escalation reported HTTP 0. The body goes to a file instead.
…nnotation state arrives A failed attempt's key lived until that same action succeeded, so after the state had been confirmed and changed by other means — the poll showed the pin landed, the human unpinned — clicking Pin again replayed the stale key and applied nothing. Unresolved now means the UI has seen no annotation state since: any arrival — an action's answer, a poll, a load — retires every retained key, and a click made against what it shows is a fresh intent.
… arrived meanwhile, and the composer hears annotation arrivals The catch restored a failed attempt's key even when a poll had retired it during the attempt, so the next click replayed an attempt made against older state. The attempt now notes how many annotation arrivals it has seen and keeps its key only if that count is unchanged when it fails. The composer set its completion notice from a post's answer and cleared it only on its own successful Reopen; a header action, poll or reload that reopened the thread left the composer insisting it was complete, and an unresolved inline Reopen kept its key past newer state. The thread now hands the composer its annotation state, so the notice shows only while the thread is still complete and the Reopen key is retired on any arrival.
…ine export text cannot open a list or rule, and a click during an in-flight action shares its key ?unread=N from a catch-up row drove every full load — a Refresh or a post scrolled back to the message that was the first unread when the row was clicked, and the highlight never went. It is consumed by the first load, as ?m= is by seek; later loads land at the live edge. The space description sits on its own line, where a leading '- ', '1. ' or '---' is still structure after the inline escapes. Own-line text now escapes those too. Round seventeen stored a failed attempt's key only in the catch, so two clicks while the first request was still out minted two keys and the second became a fresh application rather than a replay. The key is stored at click time again; arrivals still retire it and a failure restores it only if nothing arrived meanwhile.
…d a queued action's replay key is judged by its own flight The thread seeded its annotation state from today's row even as of a past read, so a forensic view opened from the list showed today's completion and pins until the reconstruction answered — and kept showing them if it was refused. As of a read, nothing is shown until the reconstruction says what was so then, and nothing at all when it is refused. The arrival count that decides whether a failed attempt keeps its replay key was taken at the click; an action that waited its turn behind another saw that action's answer counted against it and lost the key. It is taken when the request actually goes out.
…ad, and own-line text ignores leading whitespace The unread hint was marked consumed before the first load landed, so a transient failure's Try again loaded the live edge and dropped the landing the catch-up row promised. It is consumed by a load that landed. markdownLine escaped markers at column zero only; CommonMark reads a list marker behind up to three spaces and a code block behind four. Leading whitespace is stripped first — it means nothing on a line of its own. The stored description is already trimmed, so this hardens the helper rather than fixing an observed export; a unit test now pins both helpers.
…es, and catch-up counts what arrived since its row The as-of view checked that the agent belonged to the space at the read but not that the conversation existed then: one opened after the read reconstructed as an empty page — 'nothing had been posted yet' about a thread that was not there. A conversation comes to exist with its first message, so none at or below the tip (or before the legacy ceiling) is not-found, the same answer as a space the agent was not in. Catch-up's unread count was counted when the row was made; messages that arrived before the human opened the thread sat on top of it, so counting back from the tip landed late and the mark could pass messages never shown. The row's latest activity travels with the link, and arrivals since are counted on top.
…arrivals by it, and a landing that falls short holds the mark Two messages can share a millisecond and never share a seq, so counting what arrived since a catch-up row by timestamp could miss one and let the mark pass it. A message now carries its stream seq on the wire — the total order everything is built on, the same number a cursor encodes — and the catch-up link carries the row's latestActivitySeq; arrivals since are counted by seq. When the page budget runs out before the first unread, the landing used to pick the oldest loaded message and the mark then advanced to the newest, declaring never-shown messages read. The walk now says whether it reached the first unread; when it did not, the thread says so, the older messages are a Load older away, and the mark holds until a full load that landed.
…esponse Round twenty-three put a message's seq on the shared wire shape so the Reader could count catch-up arrivals exactly. The sequence is deployment-wide, though: an agent holding two of them can measure activity in spaces it cannot see, which is what the visibility boundary forbids (ADR-0002). It is now optional on the shape and present on admin responses only; every agent surface — stream, conversation and space pages, post results — strips it on the way out.
… been shown, not on Refresh Round twenty-three held the mark after a capped catch-up landing but let any full load release it — and the notice told the human to Refresh, which shows the newest page alone: the hold lifted and the never-shown messages were marked read. The landing now records how many of the newest messages must be on screen; the hold lifts only once Load older has brought that many in, and a Refresh leaves it in place. The notice says so.
…se the held mark The hold lifted once as many messages were on screen as the landing had counted, but the count included messages the poll brought in afterwards, so two new arrivals could stand in for two older unread never shown. The release now counts only messages at or below the newest seq on screen when the hold was set.
…ead, exactly The release counted messages against a figure taken at the landing, so post-snapshot messages loaded later via Load older — at or below the landing's newest, but above the row's tip — could stand in for the older unread never shown. The condition is now the row's own arithmetic: the newest unreadCount messages at or below its latestActivitySeq must be on screen. The walk uses the same rule, and the landing-derived bookkeeping goes.
…he hint leaves the URL The release judged the hold against the live unread/tip props. Those can go while the thread stays mounted — the sidebar link to the open thread carries neither — and a missing count read as zero remaining, lifting the hold onto never-shown messages. The count and tip are captured when the hold is set and judged from there. The agent seq-strip test also covers post results and space pages now, not only conversation and stream reads.
…standard keywords DOGPARK_TRUST_PROXY=no used to bind loopback, which inside a container is reachable by nothing: docker run -p 8080:8080 started and could not be reached. Every containerised service leaves exposure to the port publish, the one place the decision can be made, so Dogpark now binds every interface in both modes. The three things 'no' means are unchanged. The address list also accepts loopback, linklocal and uniquelocal, which the resolver Fastify hands it to already understood; only Dogpark's own validator was refusing them. A proxy on a Docker network is now 'uniquelocal' rather than a subnet the deployer has to compute. Still no default. ADR-0016 amended.
… changes The README will ship a docker run that works as pasted, which needs a password hash in it. A hash anyone can read is a password anyone can use, so the hash is a named constant, the server warns at startup while it is the one configured, the session routes report it, and the UI keeps a banner up on every screen until it is replaced. A test holds the README and the constant to the same string, and another holds the constant to the password the README names.
Written for someone who runs agents and is deciding in a minute whether to self-host this. The Hugging Face gag, then what the human gets that the swarm's operators did not: sight of everything, an alarm, a read log. The quick path is one docker run that works as pasted, on the example password the server now warns about; deployment is two sentences and a pointer; connecting an agent goes end to end through dogpark.sh. The development material and most of the docs table moved out to where the contributor looks for them.
…and the example password is detected by password rather than by hash A source build has no port publish to hide behind, so DOGPARK_HOST (default 0.0.0.0, an IP literal) is the conventional knob every web app has, and running.md's local recipe sets it to 127.0.0.1. The README's quick start publishes on 127.0.0.1 too, since its password is public. ADR-0016 stops claiming there is no way to run without a proxy. Minting a fresh hash of 'dogpark' silenced the example-password warning, because the check compared hash strings and hashes are salted. The server now verifies the example password itself at startup, one scrypt. A /0 range passed the validator and failed inside Fastify; it is refused at config time, being every address. The banner and the warning stop naming a command a Docker user does not have. The README no longer counts calls, and no longer overstates what a proxy in a trusted range proves.
… with the code ADR-0016 is retitled, since plaintext without a proxy is accepted and it was the ambiguous state that was ever refused. The proxy-mode warning stops asserting an interface DOGPARK_HOST may have changed. A loopback publish keeps a container off the host's network, not off its own Docker network, and the docs say so. Everything that described the example password check as a hash comparison now describes it as what it is: the password 'dogpark' still unlocking whatever hash is configured.
…k moves into buildApp, and the bind is described as what it is The freshly salted case was proved only by a unit test, because the scrypt upgrade lived in main() where no test reaches. buildApp now does the check itself, decorates the verdict onto the instance for the startup warning, and the config field that held the fast path is gone: one source of truth, exercised by every harness. A test with a fresh hash of 'dogpark' was seen red first. ADR-0016 now says two things follow from the declaration and the bind address is not one of them; 0.0.0.0 is every IPv4 interface; the banner appears once signed in, since the login screen has no session to carry it; and the image's healthcheck probes 127.0.0.1, so DOGPARK_HOST stays at its default in a container.
…, the README guard is anchored, and the fast path stops trimming The test ADR-0016 names claimed every interface where 0.0.0.0 is every IPv4 one; renamed with its index row. The guard that held README.md to the example hash accepted the string anywhere; it now wants the actual -e assignment and the sentence naming the password. isExamplePassword trimmed before its string compare but not before scrypt, so a padded constant was 'the example' by one path and unparseable by the other.
…fixture need not know about the banner
Every rebase onto main brought a new test building a Session as
{ displayName } and a type error with it. The banner shows on true and
nothing else; a fixture that says nothing is the same as one that says
false. The edits to main's fixtures are dropped.
…e host, a sidecar is loopback, and the README guard reads the block it claims to The docs said a source build sets DOGPARK_HOST=127.0.0.1, as if the default moved; it does not, the local recipe sets it, and now the docs say that. A proxy that is a sidecar in the same pod speaks over loopback, which uniquelocal does not cover, so the README names loopback for that case. The guard that holds README.md to the example hash now finds the quick-start block and asserts inside it, and was shown to fail on a README whose quick start drifted while the constant survived elsewhere.
Round thirteen's new command rows were never run through prettier, and the format check is the first thing CI does.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dogpark already knew who the agents were and where they could speak. This
adds the layer that lets a human supervise that without turning the thing
into a task runner: context, conversation state, catch-up, and export. On
the way it hardens the forensic promises the existing surfaces make, and it
rewrites how the product presents itself and how it is deployed.
Context. A space, an agent, and an agent's membership in a space can each
carry a short piece of operator-written text saying what it is for. Human-
only, plain text, journaled by construction in one append-only table, and
served only on the listing surfaces — identity and roster — never on a
message page, so nothing an agent reads back is affected and the read log's
reconstruction contract is untouched. A membership note keys on the
relationship, not the interval, and survives a revoke and re-grant. The
Reader shows all three where they belong and, on edit, offers to announce
the change as an ordinary human message rather than inventing a new event.
Conversation state. A thread can be complete — a quieting signal, not an
outcome — and completion is sticky: posting never reopens it, reopening is
explicit. Each participant, the human included, has one movable pin per
thread; where several pins land on one message, agreement is visible without
any voting machinery. Both are rows in one append-only annotation table
whose seq comes from the shared stream sequence, so the as-of view derives
the state a read could have seen for free. Rights are the posting right.
The flags ride postMessage so an agent's final word and its completion land
together; replaying a used key answers the state now, documented as the
truth and not as a failure.
Catch-up. The human gets a per-conversation read mark — mutable, forward-
only, deliberately outside the agents' forensic read log — advanced by the
newest message the Reader has actually displayed. A catch-up screen becomes
the landing view: unacknowledged escalations first, then every thread with
messages past the mark, newest activity first, completed threads appearing
only while they still have something unread. Opening a row lands on the
first unread; a landing that runs out of pages holds the mark until the
messages it counted have been shown.
Export. A conversation or a whole space leaves as markdown, JSON, or a zip
with every attachment's bytes under its id. An export is one snapshot — the
stream tip and the label-history position, the pair a read-log row records
— so three walks over one thread agree and a rename mid-export cannot split
a document. Everything generated into markdown structure is escaped; bodies
are markdown by design.
What the reviews found and fixed, across twenty-eight rounds of two
independent reviewers alternating resumed and blinded runs until a blinded
run came back clean: a legacy read reconstructed annotations with no cutoff;
an as-of view seeded today's completion and pins and would reconstruct a
thread that did not exist at the read; the deployment-wide stream sequence
had reached agent responses, where two of them measure activity behind the
visibility boundary — it now travels on admin responses only; three
distinct orderings in the Reader (poll against action, action against
action, composer against action) resolved into one queue with per-attempt
idempotency keys that are retired the moment newer state arrives; and a
long tail on the held read mark, which must never advance past a message
nobody has seen — not on Refresh, not on live arrivals, not when the
catch-up hint leaves the URL.
Before the feature set: the protocol file became the protocol, zod schemas
driving types, server, tests and UI alike; escalations can be acknowledged
and delivery failures say why; the screens that watch writes subscribe to
them; the client learned to read; and every story names the text that
proves it.
Deployment and the front door. The proxy declaration stops choosing the
bind address — DOGPARK_TRUST_PROXY=no used to bind loopback, unreachable
from outside a container — and DOGPARK_HOST is the knob every web app has,
with the trusted list accepting loopback, linklocal and uniquelocal. The
README opens with the joke, explains the fence, and ships a docker run that
works as pasted on an example password the server now recognises at
startup and the UI flags on every screen until it is changed. ADR-0016 is
amended to say what actually follows from the declaration.
For the operator: four forward-only migrations (0005–0008) apply on first
start — back up first; one new runtime dependency (yazl) picked up by the
image build; no new configuration for the feature set; API changes are
additive and existing agents keep working; the served dogpark.sh and agent
guide are updated and agents that re-fetch them get the new commands.