Browsable URLs: people at the root, one address per plan, live libraries - #191
Merged
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 092f582755
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Every prefix of a CoPlan URL is now a real page. `/l` lists the
libraries, `/l/:handle` is a library root, and each folder below it is
its own address:
/l/orders/live-cart/roadmap
Resolution walks the path a segment at a time, filesystem-style, rather
than matching a stored joined string. That's what makes a folder rename
free: the folder's own slug changes and every URL beneath it follows,
with no rows to rewrite. Folders win ties on the last segment — a folder
has children, so mistaking one breaks a whole subtree.
Leaf segments strip whatever the path already says: the library handle,
every folder on the way down, and the plan type. A plan titled
"LiveOrder Cart Roadmap" filed under LiveOrder is just `cart-roadmap`,
which is the point — a folder full of `liveorder-*` plans is unreadable.
Comparison ignores hyphens, so it works whether the folder was named
"LiveOrder", "Live Order", or "live-order". Slugs follow the title;
a `~abcd` suffix appears only where a slug is actually contested, so
clean URLs stay clean.
Old links keep resolving. `coplan_url_aliases` holds one prefix row per
rename — O(renames), not O(documents) — plus an exact row per retitle,
and it's a rebuildable cache over the existing event logs, not a record
of truth. Draft retitles record nothing, and rows that never get hit can
be pruned. Legacy `/libraries/:id` and `/library` 301 onto the canonical
path so address bars converge instead of forking.
`/l/` seals its own namespace, which kept this a pure addition: no
existing route moved, and the reserved-handle list is five names.
Access control is unchanged and stays a DB predicate — `Plan.visible_to`
decides what a browser sees. A readable URL is not a permission.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A plan lived on any number of shelves before: `has_many :placements`, a Save button that filed someone else's document onto your own, and a "which library is this in?" question whose answer depended on who was asking. That made a canonical URL impossible — a document with three locations has three addresses and no home. So it lives in exactly one place now, the way a file does. Filing it somewhere else is a move, never a second copy. `Plan#library` is wherever it's filed, falling back to its author's library when it isn't filed at all, and every reader sees the same location because there is only one. Moving takes authority on both sides: write access to the destination and a claim on where it sits now (authorship, or write access to the library it's currently in). That second half is the seam that will let a team reorganize its own library without letting anyone walk off with someone else's document. `from_library_id` on the Organize move ops becomes a no-op — filing somewhere new already takes it out of where it was, so there's no source side left to name. With one location settled, `/plans/<uuid>` stops being canonical. It 301s onto the readable address — permanent, because the id form is the page's old name, not a redirect-of-the-day — carrying the query string so `?thread=` deep links and the legacy `?tab=history` hop still land. Every link the app generates now points at the readable form directly, including push payloads, so nobody pays for the extra hop. The document page carries `rel=canonical` for anything that arrives the old way. Path building moves to `Urls::Canonical` so controllers, jobs, and push payloads can reach it without a view context; `BrowseHelper` delegates. Also fixes slugs mangling non-ASCII titles — "incorporación" became `incorporacio-n` and a Japanese title became `2027`. Slugs normalize NFC and keep Unicode letters; library handles stay ASCII, since a handle is the root of every URL under it and gets typed and read aloud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the canonical-URL switch broke or exposed. **Cross-document links stopped reading as documents.** Reference only knew `/plans/<uuid>`, so every link copied out of the address bar after the switch — which is now every link — landed in the References footnote as a generic "link" with no target_plan. It recognizes the readable form too, resolving the path to an id through the same segment walk the router uses, aliases included: a link written before a rename still names the document it was always about. **The truncation lists never included libraries.** Two specs run without transactional fixtures and clean up by hand; their lists predate libraries and folders, so those rows accumulated across runs. A library handle is globally unique, so a leaked row keeps "alice" reserved — and the list is now named once instead of copy-pasted twice. **`/l` could omit your own library.** Libraries are materialized on first touch, but the index read the table directly, which is exactly the path that skips the invariant. A viewer who hadn't yet loaded a page linking their library got a list without it. It had looked fine only because a stale row from an earlier run happened to be sitting in the test DB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HamptonMakes
force-pushed
the
hampton/browsable-library-urls
branch
from
August 22, 2026 18:23
092f582 to
be49c03
Compare
The `/l/` prefix was doing nothing but taking up the segment that should
carry the most weight in a shared link. Handles move to the root, so a
document's address reads like the person whose library it's in:
/sam Sam, and Sam's library
/sam/liveorder a folder
/sam/liveorder/cart-state-machine a document
**Everything that isn't a place moves under `_`.** Settings, search,
notifications, the cross-library plan view, and the id-based mutation
endpoints. That single character is the whole reservation: a handle can
never *be* `_`, because slug rules strip non-alphanumerics, so the split
is structural rather than a list someone has to keep updating. `_` is
reserved inside a library too (`/sam/_/…`), by the same mechanism, so
library-scoped pages have somewhere to go that can't collide with a
folder name. Rails' `scope "_"` prefixes paths and not helper names, so
`settings_path` and the rest read the same at every call site.
The legacy block in the router is the one fixed list, frozen at the paths
that shipped before this — all 301s. `/api` and `/agent-instructions`
stay at the root: published contracts, in every API response and in
llms.txt.
**A person's page is their library.** `/people/:id` rendered a read-only
shelf; `/l/<handle>` rendered a different read-only shelf. Neither had
filters, folder counts, or "since you last looked" — which is what made
someone else's library feel like a lesser app rather than the same app
pointed somewhere else. Both are gone. One page renders every library,
and what you can *do* to what's in it is a question for the buttons
(`Library#writable_by?`, as `@can_write`), not for which view to render.
The identity that lived on the profile page is now that page's header.
Two things fall out of that. Library rows are created with the user
instead of on first touch, because a link to a colleague who has never
signed in has to work — the migration backfills everyone who predates
it. And folder navigation is a path now: it was built out of
`?folder=<id>` against your own workspace, so clicking a folder in
someone else's library bounced you into yours.
**Libraries are live.** Filing a document, moving it, retitling or
hiding it, creating or renaming a folder — all of it changes what a
library page shows, and none of it reached the page. Each now tells the
library, and every browser watching it re-fetches: readers included,
since a filter you're looking at is as stale as one you could edit.
`broadcast_refresh_to` rather than streamed fragments, because no
fragment can be shared — visibility is per-viewer, folder and filters
are per-viewer, forms carry per-session tokens. The broadcast carries no
content, just the news that there is some. With morph + scroll preserve,
a page you're reading doesn't blink, lose your place, or close the
popover you just opened because someone else filed something. Per
library rather than per folder: a rename high in the tree changes every
listing beneath it.
**Readable links needed a host check.** `/plans/<uuid>` was
self-identifying; `/sam/liveorder/cart-roadmap` is shaped like any other
site's URL, so matching on shape alone would have typed half the links
people paste as CoPlan documents. Classification now takes the request
host; resolution stays separate from it, because the extractor runs in a
job with no request to ask.
Also: the host's copy of the URL-segments migration was installed before
that migration learned about Unicode folder slugs and ASCII handles, so
it had been running the old rules. Re-synced, and the handle backfill now
starts from the reserved list so a person whose ldap is "settings" gets
one the app would actually accept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things from review, three of them real. **A folder could take a document's address.** `contested?` asked only whether another *plan* held the slug, but `Urls::Resolve` hands the segment to a folder when both want it — a folder has a subtree, so mistaking it for a document breaks more than one page. A plan sharing a sibling folder's slug therefore had no reachable address at all, and `/plans/<uuid>` 301'd onto the folder. Folders now count as siblings, in both directions: a plan created next to a same-named folder takes the suffix, and a folder created or renamed onto a plan's segment re-slugs the plan it would have shadowed. Folders never take a suffix — a folder's segment appears in every URL beneath it — so the plan is the one that moves. **A prefix alias skipped the path it was recorded for.** Candidates were every *ancestor* of the requested path and not the path itself, so renaming a folder fixed every link into it except the link to the folder, and a renamed library handle was never matched at all: a one-segment path has no ancestors. **Library counts left out loose plans.** A plan at a library root has no placement row by design, so counting placements alone called a library of nothing but unfiled work "empty" — and undercounted mixed ones, while browsing the library showed them. **Mount prefix.** `plan_browse_path` in views now builds from the view's own route helpers, so a host mounting the engine at /coplan keeps the prefix. `Urls::Canonical` stays as-is and says why: it exists for callers with no request, where engine route helpers can't know the mount point — the same limitation SlackNotificationJob and the API base controller already live with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HamptonMakes
added a commit
that referenced
this pull request
Aug 23, 2026
Codex caught a real false positive: a renamed heading files an untouched body under a new slug key, which the key-level diff can't tell from newly written text. Rename three headings in a four-section plan and both rewrite thresholds pass, so the reader is told the plan was rewritten when only three heading lines moved. Bodies that already existed somewhere in the old document are now carried over rather than written, and don't count toward "most of this is new". Matching is by exact body, one old section per new one, so repeated boilerplate can't discount two sections at once. Renamed sections still highlight — the heading did change — they just no longer argue for the notice. Also: /plans/:id redirects to the canonical browsable URL since #191, so the two request specs added here follow the rebased ones onto plan_page_path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HamptonMakes
added a commit
that referenced
this pull request
Aug 23, 2026
* Say "rewritten" instead of highlighting the whole plan The "changed since you last looked" highlights were making plan pages look messy in the two most common cases. Open a plan while an agent is still drafting it — or come back after a rewrite — and every section differs from your baseline, so every section lit up. The diff was right and the page said nothing. Plans::ChangedSections now reports that case as a rewrite instead of a list of keys: most of the document is new, measured against both the section count and the volume of text (either measure alone misreads a common shape — a swarm of one-line sections changing isn't a rewrite, and neither is one long section getting edited), and only for documents of four or more sections, since banding a short plan in full is a few inches of tint rather than noise. The page drops the highlights and carries one line above the content: "Rewritten since you last looked", linking to the history. Second, unrelated fix in the same feature: the controller tinted each top-level block separately, so one changed section rendered as a stack of rounded boxes with untinted gaps and a broken-up left bar. Adjacent changed blocks are now grouped into runs, and the inter-block margins inside a run become padding within the tint, so a run reads as one continuous band — rounded at the ends only, with mid-run headings keeping their breathing room inside the band instead of punching a gap through it. Verified against the real page in both states, before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Don't count renamed headings as rewriting Codex caught a real false positive: a renamed heading files an untouched body under a new slug key, which the key-level diff can't tell from newly written text. Rename three headings in a four-section plan and both rewrite thresholds pass, so the reader is told the plan was rewritten when only three heading lines moved. Bodies that already existed somewhere in the old document are now carried over rather than written, and don't count toward "most of this is new". Matching is by exact body, one old section per new one, so repeated boilerplate can't discount two sections at once. Renamed sections still highlight — the heading did change — they just no longer argue for the notice. Also: /plans/:id redirects to the canonical browsable URL since #191, so the two request specs added here follow the rebased ones onto plan_page_path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
HamptonMakes
added a commit
that referenced
this pull request
Sep 3, 2026
… way to wake an agent (#197) * Agent collaboration: live event inbox, presence pill, diff flashes, bridge Agents that author plans can now hear about comments the moment they land and collaborate visibly: - AgentEvent inbox + AgentSession (Linear-style pending/active/ awaiting_input/complete/stale) with fan-out from the existing notification choke point; agents never woken by their own activity. - GET /api/v1/agent/events long-poll + SSE with UUIDv7 cursor resume and explicit ack; POST/PATCH/DELETE /api/v1/plans/:id/agent_session drives a live presence pill on the plan masthead. - Content broadcasts now carry changed-section keys; live_update flashes changed blocks with word-level ins/del diffs that settle after ~2.5s. - API ergonomics: GET single comment thread, dismiss route alias (docs said dismiss, router said discard), API threads get the same initial- status rule as the web flow, agent_name on ApiToken. - script/coplan-bridge: harness-agnostic daemon (claude/codex/goose/ openhands/amp adapters + in-process demo agent) that drains the inbox and resumes your local harness session per event. - /agent-instructions documents the realtime loop and session etiquette. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Voice tier, collaboration docs, and API orphan-thread fix - Push-to-talk mic button on the plan page (Web Speech API tier): speaks feedback into a comment, listens for the agent pill to speak "Got it." / "Done — take a look." cues. Hidden when unsupported. - voice/ Pipecat sidecar scaffold (MLX Whisper + Kokoro via OpenAI-compatible endpoint) documenting the higher-fidelity local pipeline and its provider plugin seams. - docs/AGENT_COLLABORATION.md: the live feedback loop, bridge config, per-harness adapter recipes, permission-posture caveat, local demo. - API fix: wrap thread + first comment creation in a transaction so a failed comment (e.g. missing agent_name) can't leave an orphan empty thread; regression spec included. - Bridge: unbuffered stdout, drop a dead line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Attach mode: interrupt a live agent instead of waking a dead one The bridge assumed the agent had exited and needed a harness resume. The normal case is an agent that is already alive and watching, which doesn't need waking — it needs interrupting. - script/coplan-attach: one held SSE connection, no config file, no adapters, no daemon. --once blocks until the first event, prints a brief, acks, and exits (the shape a turn-based agent wants); bare mode streams. Holds the presence pill while attached, detaches cleanly on exit. - /agent-instructions leads with the held-open stream and states that no daemon or harness integration is required; the bridge is now documented as the cold-start path only. - Event typing: derive comment.created vs comment.replied from whether the comment opens the thread, not from the notification reason — an agent opening a new thread was being reported as a reply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix ghost presence pills and the pill gap during an agent's first turn Two defects found by running the loop against a real agent session: - A killed agent left its session `pending` forever, showing a second ghost "Claude is on it…" pill. Staleness was enforced only by MarkStaleAgentSessionJob, and nothing enforces that a job worker is running. `AgentSession.visible` now computes staleness at read time with per-state windows (pending 30s, active 5min, awaiting_input 1hr, falling back to updated_at). The job remains, but only to broadcast the removal promptly — correctness no longer depends on it. - coplan-attach --once detached on exit, clearing the pill at exactly the moment the agent began working, so a slow first turn looked like nothing happening. It now hands the pill off as `active` before exiting instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stop attached agents from starving the web server; signal-driven wakes Measured problem: with three agents attached and RAILS_MAX_THREADS=3, a plain page load timed out after 10s. Every held connection (SSE or long-poll) occupies a Rack thread, and nothing bounded how many agents could hold one. - New AgentEventBus owns two concerns: a held-connection budget (RAILS_MAX_THREADS - 2, override with COPLAN_MAX_AGENT_STREAMS) and wake/notify for waiting connections. - Over budget, long-poll degrades to a non-blocking read with "throttled": true and SSE is refused with 503 + Retry-After, so agents never queue ahead of ordinary requests. Same three-agent load now serves a page in ~25ms. - Waiting is signal-driven instead of a 500ms poll loop: AgentEvents::Publish signals the bus, measured ~180ms end-to-end from comment posted to long-poll returning it. Waiters still wake every CROSS_PROCESS_INTERVAL so writes from other Puma workers are caught. Ceiling is still thread-per-agent — fine for a team, not for hundreds of concurrent agents. Documented in docs/AGENT_COLLABORATION.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove three API frictions found by driving the loop as a real agent All three surfaced while a live Claude Code session worked a plan over the API, not in tests: - agent_name had to be repeated on every comment and reply even though the agent already declared it when claiming its session. It now falls back to the agent session's name, then the token's agent_name, then the token name; an explicit param still wins so one token can post as a different persona. An over-long name is truncated to the 20-char display limit instead of losing the comment mid-conversation. - Comment create/reply returned only comment_id/thread_id, while the rest of the API returns `id` for the created resource. `id` is now included alongside the existing keys. - Comment had no dependent: on its notifications, so destroying one died on a foreign key constraint. Notifications are delivery records for that comment and are deleted with it. Two comments_spec examples asserted the old "reject without agent_name" contract; they now assert the fallback attribution instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add a `watching` state so an idle attached agent stops claiming to work The presence pill read "Claude is working…", pulsing, while the agent sat idle on a socket doing nothing. There was no attached-but-idle state, so coplan-attach had to claim something on connect and picked `active`. A pill that animates when nothing is happening trains people to ignore it. - New `watching` state, displayed as "Claude is watching" — no ellipsis, no verb of effort — with muted chrome and a static dot. - agent_session#create honored neither `state` nor `detail`; it hardcoded "active" and dropped the detail. It now accepts both and defaults to `watching`, so claiming means "I'm here", not "I'm busy". - Liveness comes from the stream's own 15s heartbeat touching the session, so a watching pill lasts exactly as long as the connection and expires ~2min after it dies. - watching → pending on a new event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix ghost agent pills and give attached-but-idle agents their own state Three bugs found by using the feature: - AgentEvents::Publish woke *every* session for a token, including ones whose process was long dead, so a killed agent's pill reappeared on each new comment. Gate the wake on session.live?; events are still created for detached sessions so the durable inbox is unaffected. - Sessions had no attached-but-idle state, so a watching agent rendered as "Claude is working…" before it had done anything. Add a `watching` state that displays just the agent name with a pulsing green dot. - POST /agent_sessions hardcoded state: "active" and dropped `detail`, and clobbered `awaiting_input` when an agent reattached. Honor both params and preserve awaiting_input on a bare reattach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add an authority model for comments and mintable session tokens Two halves of the same question: what is an agent allowed to do, and who is it doing it as. **Authority.** Comment events now carry `from_principal` (the commenter is the human this token belongs to), `from_plan_author` (the commenter wrote the plan), and an `authority` summary of "principal" or "collaborator". The two flags come apart deliberately — an agent can be attached to someone else's plan, and its own principal still outranks that plan's author. The documented contract: a principal's comment may be acted on directly; anyone else gets a reply and a proposal in-thread, not an edit. Because a local_agent comment stores the user behind its token, a second agent working for the same human speaks with that human's authority. **Session tokens.** A token is the unit of event subscription, so two agents sharing one share an inbox and race for each other's wakes — which is why running the multi-agent demo meant hand-creating tokens in the settings UI. POST /api/v1/tokens mints a short-lived child from a long-lived one: same principal (never escalates), 12h default TTL clamped to 7 days, one level deep, and revoking the parent revokes everything it minted. DELETE /api/v1/tokens/current lets an agent clean up its own credential on exit. coplan-attach grows --mint-only (print a token to export for the session) and --mint (mint, use, revoke on exit). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Keep the session token sticky, and out of the agent's hands entirely Minting a token per agent run only helps if the token survives the run. An agent that re-minted every turn would get a new inbox and a new presence pill each time; an agent that kept the token in its context would lose it to compaction — and go looking for it in places (shell history, transcripts) where hunting for a credential is exactly the wrong shape. So the token lives in a file keyed to the agent run, and the tools read it: - script/coplan_session.rb stores one minted child token per session key in ~/.coplan/sessions/<key>.json (0700 dir, 0600 file), reusing it until it nears expiry. The key defaults to the working directory — one agent per checkout is the usual shape, and unlike a harness session id it survives compaction and restarts. Harness ids are checked first but not depended on: Claude Code's CLAUDE_SESSION_ID isn't consistently exported to tool subprocesses. - script/coplan is a small authenticated client (get/post/patch/put/ delete, plus reply/say/session/whoami) so no call an agent makes ever carries a token on the command line. A 401 — revoked token, revoked parent — silently re-mints once instead of surfacing an auth error. - coplan-attach uses the same sticky session, replacing the ephemeral --mint flags. The token deliberately outlives the process so successive --once turns share one inbox and one pill. Minting stays best-effort: if the server can't mint (older server, or the caller already holds a session token) everything falls back to the token as given. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Voice commenting: pin dictated comments to the passage they're about Dictating a comment worked, but the comment went nowhere visible. An unanchored thread is filtered out of the plan page entirely (show.html.erb selects anchored threads; the controller only broadcasts anchored ones), so a voice note existed in the database, reached agents through the event inbox, and appeared on the document not at all. Voice comments are now anchored, by two mechanisms: - Floor: the heading of the section on screen when you spoke. Cheap, always available, and roughly where you were looking. - Better: the AI picks the actual span. A new anchor_suggestions endpoint sends the model only the visible text plus the transcript and asks which passage the remark was about, so "this bit is too cautious" highlights the sentence rather than the section. The model is never trusted — a span that doesn't appear verbatim in the excerpt is a paraphrase and is discarded. The call is time-boxed at 4s client-side and every failure path (no AI configured, slow model, paraphrase, network) falls back to the heading. Two fixes found by testing it: - anchor_occurrence is 1-based server-side (resolve_anchor_position bails below 1), so a 0-based count silently produced a thread with anchor_text and no resolved position — a pin pointing at nothing. - The mic lived in the masthead toolbar, which scrolls away, so you had to leave the passage you were talking about in order to talk about it. It's now fixed to the viewport — and rendered at page level, because .plan-actions sets backdrop-filter and would otherwise become the containing block and pin it to the toolbar. The UI also stops promising an agent. It says what happened ("Comment added to …"); if an agent is attached and picks it up, its pill and spoken ack say so, and if none is attached this is simply a dictated comment that waits in the durable inbox. Covered by a system spec driving the real controller against a stubbed SpeechRecognition, asserting the highlight resolves to the exact sentence, that the heading fallback still anchors, and that nothing claims an agent is coming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Clean up dictated remarks and add hold-Shift-to-talk A dictated comment used to land verbatim: "this bit is like way too like cautious". Nobody wants that in a thread with their name on it. InterpretDictation replaces SuggestAnchor and does both jobs in one round trip, because both need the same two inputs — the transcript and what was on screen. It returns the remark cleaned up and the passage it refers to. Neither half is trusted: a span that isn't in the excerpt character-for-character is a paraphrase and can't be highlighted, and a rewrite that changes length dramatically has stopped being a cleanup and started being a summary. Anything that fails falls back to a local tidy-up of what the person actually said, which the server now does too — the client's version never ran, since a raw transcript comes back looking like a successful response. The tidy-up is deliberately timid about "like": it's a real word far more often than a tic, so it only goes where the sentence marks it as filler. Holding Shift starts listening; releasing sends. Shift is also held for capitals and selections, so three guards keep it from firing by accident: Shift alone, held past 350ms, and any other keystroke aborts without posting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Record audio and transcribe server-side; fix table-cell anchors Two problems, both found by using it. **Nothing got pinned.** The excerpt sent to the model is the rendered text of what was on screen, one block per line. The anchor has to resolve against the markdown source. Those agree for a paragraph and part ways at a table: adjacent cells read as adjacent lines, so the model quoted "Voice (mic button)\n24%", which appears nowhere in "| Voice (mic button) | 24% |". The thread was created with anchor text and no position — a comment that is simply invisible on the page. InterpretDictation now takes the source document as well and narrows a span to the longest line that actually resolves, or gives up so the caller falls back to the section heading. **The transcription was the weak link**, not the pinning. Browser speech recognition is Chrome-only in practice and guesses phonetically at anything domain-specific. The control now prefers MediaRecorder and has the server transcribe with gpt-4o-transcribe, passing the visible text as the decoder prompt so product names and figures come back as themselves. That works in Safari and Firefox too, where the mic previously just hid itself. Recognition stays as the fallback when no provider is configured. The trade is a round trip and no live captions while you talk; accuracy is worth more, since a comment that says something you didn't is worse than no comment. Verified against the real API end to end: "the higher fidelity sidecar stays behind a flag until it is proven" comes back correctly hyphenated from the context hint. The WebM/Opus path Chrome uses is covered by format mapping and specs, not by a live round trip — no ffmpeg here to make one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Never post a recording nobody spoke into The comment on the trunk-based development plan read "Adopt Trunk-Based Development." — the page's own heading, pinned to itself, attributed to someone who had said something else entirely. Whisper-family models answer silence by repeating their prompt, and we prompt with the text that was on screen to get the jargon right. Two seconds of digital silence through CoPlan::Ai.transcribe returns the first line of whatever context it was handed; confirmed twice against the live API. So an empty recording doesn't fail, it fabricates, and what it fabricates is always plausible because it came off the page. Three defences, because one is not enough for a failure that invents content in someone's name: - The browser meters the microphone while recording and won't send a take it heard nothing in. Peak level also drives a ring around the button, which is the only "it can hear you" signal the recording path has — there are no interim captions. - The server rejects any transcript wholly contained in the prompt it sent. Reading a sentence off the page aloud trips this too; being told to repeat yourself beats a comment putting words in your mouth. - Releasing before getUserMedia resolves — the norm on first use, while the permission prompt is up and you are already talking — now says the mic wasn't ready instead of leaving it stuck listening. Metering never blocks a recording: if it can't run, assume speech. Refusing to post what somebody said is the worse failure. Errors now carry the server's wording, since "heard nothing" and "couldn't reach the transcriber" call for different next moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Don't let a dead meter call speech silence; say misses out loud "It should be main and master", spoken clearly, twice, answered both times with "couldn't hear you". The mic was fine and the recording had the words in it — the silence guard from the previous commit was reading a meter that never turned on. An AudioContext starts suspended unless the browser saw a qualifying user gesture, and Chrome does not count a bare Shift keydown as one. So on the push-to-talk path the metering context routinely comes up dead, and a suspended analyser reads exactly like a silent room: peak 0. The guard meant to stop fabricated comments was throwing away real ones — and only on the hold-to-talk gesture, which made it look like the gesture was broken. The button path worked because a click is a gesture. The meter's verdict now only counts if the context actually reached "running" during the take (plus a resume() nudge for contexts that are merely waiting). A meter that never ran gets no vote and the recording is sent — the server's prompt-echo check remains the defence against true silence, and it distinguishes the cases anyway. Misses are now also spoken, not just printed: "Hmm, didn't hear anything" over speechSynthesis, same words as the status chip. In a voice flow you're talking, not watching a corner of the screen — the silent failure was why two rejected takes in a row read as mystery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Open the mic at the keydown, not after the hold is confirmed The dev log for today's session: one dictation posted, then three rejected as prompt echoes in a row. A synthetic-audio experiment cleared the transcriber — short and even mid-word-clipped real speech transcribes fine, with or without the prompt. What actually happened: hold-to-talk opened the microphone only after the 350ms hold delay plus getUserMedia latency, so for a short remark ("oh, I meant both of them") the recording began after most of the words. The near-empty tail echoed the prompt, and the echo guard told the truth: the mic really didn't hear anything. The bug was never in the guards — it was that the capture missed the speech they were guarding. Push-to-talk now opens an "ear" at the Shift keydown itself: stream and recorder start buffering immediately, silently, and confirming the hold adopts a capture already in progress. A tap, a Shift-selection, or a shortcut discards the take unheard — the cost of a false start is a blink of the recording indicator. Releasing while the mic is still opening posts whatever the ear caught instead of demanding a retry. The retry-without-prompt alternative was tested and rejected: silence transcribed with no prompt returns hallucinated filler ("Na przykład"), which would post gibberish instead. Echo rejections are now logged with the transcript so the next false positive is diagnosable from the log. Also, since dictation is conversational in a way typing isn't, the interpreter now receives the last three comments — "oh, I meant both of them" has no "them" without the comment it follows. And the mic button reads as a recorder: filled red while listening, level ring, 18px glyph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Resolve spans broken by inline markup instead of falling to the heading "What does 'releasable' mean?" transcribed perfectly and landed pinned to the page's H1 — under the mark already owned by an earlier comment, where the highlighter's one-thread-per-mark rule made it unreachable. The span was fine; the resolution wasn't. The model reads rendered text ("main is always releasable") and the anchor must resolve against markdown ("`main` is always releasable"), so any span crossing inline markup was rejected whole, and rejection means the heading fallback. The table fix only handled multi-line spans. Now resolution degrades in steps: whole span, whole lines, then the longest contiguous run of words still present in the source — here "is always releasable", which pins to the sentence being asked about. Runs under 8 characters don't count: a pin on a salvaged "the" points at noise. One thread per mark remains the rule when two threads genuinely claim the same range — overlap handling is its own piece of work. This change just stops manufacturing collisions out of comments that named their own, distinct target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Refuse comments whose anchor doesn't pin, and stop rejecting spans the resolver can place A comment whose anchor never resolved renders nowhere — no highlight, no popover, no way to reach it — yet we created it and said "comment posted". Three changes close that hole: - CommentThread validates on create that a present anchor resolved to positions; resolution moves to before_validation so validation can see it. Content drift after create stays the out_of_date flow. - InterpretDictation#anchorable now checks spans against the resolver's own stripped-markdown translation instead of a raw-substring test that was stricter than the resolver — "main is always releasable" survives backticks whole instead of being narrowed past the word it points at. - The resolver learns one more capture shape: mermaid labels broken by literal <br/> tags select as concatenated text ("firstfetching"), now matched by dropping the tags with the position map carried along. On refusal, the HTML selection form keeps the draft and shows the error inline; the voice client retries once with its viewport-heading anchor before giving up; the JSON API already returned 422 for RecordInvalid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Voice comments open themselves, and the excerpt means readable, not intersecting Two fixes from the same field report ("it's attaching this last comment to a place I can't even see"): - _visibleText counted any block intersecting the viewport, so a paragraph with one line poking over the fold was quotable and the pin could land below anything the speaker had read. A block now has to be readably on screen — most of itself visible, or a real slice of the viewport. - Nothing showed where a voice comment landed. The speaker never chose a spot — the model did — so the client now renders the create response's streams immediately, then scrolls to the thread and opens its popover (same treatment as arriving via ?thread=ID, via a coplan:open-thread event the text-selection controller listens for). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Excerpt accumulates across the take; open-thread goes through Stimulus actions The excerpt was a snapshot of the viewport at the moment talking began. People start on one paragraph and scroll to another mid-sentence, so it now accumulates every block that was readably on screen at any point — sampled at start, on each listening tick, and once more at submit. The last thing you looked at is always in; what never crossed the screen stays off the wire. Document order and live-update survival come from filtering a fresh block list against the seen set rather than serializing the set. The auto-open handoff also drops its hand-rolled document listener for the idiom this element already uses: the voice controller dispatches coplan:open-thread via this.dispatch, and show.html.erb routes it with a data-action descriptor next to keydown.esc@document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * One dictation, several comments: "rename both of these" is two placements The interpret contract widens from one {text, span} to {"comments": [{text, span}, ...]}, capped at four. Repeating a span is how two copies of the same text are addressed: the client bumps the occurrence for each repeat, so both mentions of "main" get their own pin. Every span is vetted individually — a paraphrase falls while its siblings stand — and the length trust band applies to the rewrite as a whole, with flat per-comment headroom (standing alone costs roughly a sentence per split, a constant, not a multiple of the remark). The client posts each comment with the existing per-comment fallbacks — except the heading fallback, which belongs to the remark as a whole and only the first comment takes; the rest post unpinned rather than piling onto one heading. The first thread auto-opens and the status counts the rest. The old singular body/anchor_text keys stay in the dictation response as the first comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Refuse transcripts the recording had no time to hold gpt-4o-transcribe is a chat model with ears: given an instruction-shaped remark and a context prompt, it sometimes answers instead of transcribing. Live, 'add some content about how editing works' came back as a whole essay with figures lifted from the prompt, and posted as though the person had said it. The existing guards missed it by design: the echo check catches transcripts contained in the prompt, not ones expanded from it, and the interpreter's length band compares cleanup to transcript — and the transcript already was the essay. The physical bound is the fix: the client now sends how long the take was, and a transcript past ~30 chars/sec was generated, not heard. First response: retry without the prompt, which leaves the model nothing to answer from. If the retry still outruns the clock, 422 'Didn't catch that' — the client already voices that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Render comment keyboard nav even when the page loads without threads The coplan--comment-nav div was gated on @threads.any?, decided at page render. But comments arrive live — voice dictation, selection comments, other viewers' broadcasts — so on a fresh plan the first comment appeared, its popover auto-opened, and d/j/k/r/a/s did nothing at all: the controller holding the key listeners was never on the page. Render it unconditionally; it no-ops fine with zero highlights. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Interpreter: speak as the speaker, and know where they were reading Two live failures from Hampton's testing: - 'Hmmm, not enough information on tax attach' came back as 'I will add more information' — the model replied to the remark instead of editing it, signing a promise in the speaker's name. The prompt now frames the job as writing the comment the speaker would have typed (their voice, their point of view, appears under their name), with that exact failure as a counter-example, and notes the remark usually names its own target ('tax attach' → the passage about it). - Anchors kept landing just above the visible area. The excerpt accumulates everything that scrolled past during the take, and the model had no idea which part was actually being read. The client now samples the blocks crossing the middle band of the viewport (people read the middle of the screen) at the moment of release and sends them as 'focus'; the prompt marks that as the span's likeliest home. Omitted when it would just repeat the excerpt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Align the live-agent loop with the merged identity layer The merge-back of main (#177–#182) brought the token identity work this branch originally drafted, evolved: hook-auth bootstrap minting, Bearer-required API, token metadata, api_token_id provenance. This commit finishes the reconciliation: - Self-wake suppression now keys on api_token_id explicitly. AgentEvents::Publish takes actor_token_id; comment events suppress on the comment's api_token_id and content events on the version writer's token. The old actor_id matching silently broke when attribution started storing the human's id — an agent would have been woken by its own edits. - Agent name resolution is token-first everywhere via api_agent_name, so an agent can't sign comments under one label while its versions carry another. The session-claim name no longer leaks into writes. - Dropped the branch's superseded token minting draft (controller, model methods, add_parent migration) in favor of main's. - Guarded the agent-tables migration: tables may pre-exist on schema-loaded databases, and api_tokens.agent_name ships with the identity migration on hosts that install this one later. - Removed per-controller token checks that BaseController's require_api_token! made unreachable. Suite: 1554 examples, 0 failures (incl. system). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * coplan-attach: make --timeout able to fire mid-stream The timeout was only checked between reconnects, but the server holds an SSE stream open for minutes with heartbeats — so "--timeout 6" actually meant "when the server retires the stream". Now the remaining budget becomes the socket read deadline; Net::ReadTimeout loops back to the top, where the check exits 64 as documented. Verified live: --timeout 6 exits 64 at ~6s; --once wakes on a comment in under a second, prints the brief, hands the pill to active, exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * The pill says what the server knows: waking, not working Field-tested by an Amp local thread: it held the SSE stream, received every event, and its model never woke — while the pill said "Amp is on it…". Delivery is not action, and no state copy should promise more than the server can verify. - pending now renders "Waking <agent>…" — what the server actually did. "On it" is the agent's own claim, made by PATCHing active. - Claims can only arrive in watching or active: a fresh session claimed into awaiting_input parked an unearned "asked a question" pill for up to its hour-long stale window. - pending and stale are now rejected via PATCH too — both are verdicts the server reaches about the agent, not reports an agent can file. - /agent-instructions and docs/AGENT_COLLABORATION.md spell out the harness requirement the loop stands on: event arrival must become a model turn (blocking tool call / background-exit re-invocation / sidecar resume), else drain the durable inbox per turn. Amp's webhook ask is recorded as an open design question, not built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wakes go where a path exists; promises go where one was kept The pill only knows two true things: whether a connection is parked (SSE heartbeats and long-poll parks now both stamp a transport clock — long-poll agents previously read as absent and went stale mid-loyalty), and whether this session has ever turned a delivery into a model turn. So an event only starts the 30-second pending countdown when transport is live or a wake URL is registered, and the pill only says "Waking Claude…" once a wake has been answered before — the first one is quietly a test, shown as plain presence. New wake path for agents that can't hold a connection or be resumed: claim with a wake_url and CoPlan POSTs a signed "you have inbox items" ping per event (HMAC secret minted once at registration, event_id for dedupe, retries with backoff, fires even on complete sessions — waking them is the point). The ping carries no payload: the agent pulls and acks through the cursor API, so at-least-once and authority don't fork. Fits Amp orbs' createWebhook exactly. The bridge grows an "acp" adapter: one live agent subprocess speaking the Agent Client Protocol (initialize → session/new → session/prompt per event, permission asks answered per config), replacing per-harness resume dialects for everything in the ACP registry — goose acp, Gemini --acp, claude-agent-acp, codex-acp, amp-acp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden the wake paths against the eleven ways they lied or leaked An adversarial review of the wake plumbing confirmed eleven defects; this fixes all of them. The headline: MarkStaleAgentSessionJob had never fired. wake! passed woken_at through bare iso8601, truncating to whole seconds, while the column is datetime(6) — so the wake's own transition always read as "activity after the wake" and the 30-second retraction behind every pending pill was a dead letter. woken_at now carries microseconds, and the job finally has a spec (including the mid-second regression case). Honesty of the wake proof: - Only active/awaiting_input count as answering a wake (PROOF_STATES). complete and watching are filed mechanically by detach paths and supervising loops, and were marking dead harnesses wake-proven. - coplan-attach --once no longer PATCHes active on its way out for the same reason: it cannot know whether its exit wakes anything. The woken model's own fast-ack takes the pill over, or pending goes stale in 30s — now truthfully. - Instructions spell out "PATCH, don't re-claim, when answering a wake": a fresh claim resets the session instead of answering it. Safety of the webhook egress: - New CoPlan::WakeUrlPolicy: wake URLs must resolve entirely to public address space (SSRF; one private A record among public ones is refused too). Checked at registration and re-checked before every POST, since DNS may change its answer between the two. Hosts override via config.wake_url_policy; dev/test allow localhost. - DeliveryFailed messages carry the session id, never the URL — the URL can embed a capability token and the messages land in logs and solid_queue_failed_executions. - Rescue now covers EOFError (via IOError), Net::ProtocolError, and Net::HTTPBadResponse, which previously escaped straight to the failed-executions table. - Dead-URL circuit breaker: wake_failures_count tracks exhausted retry runs; after three, the URL and secret are unregistered — mirroring how expired web push subscriptions are destroyed, not hammered. - The enqueue moved inside ActiveRecord.after_all_transactions_commit: the queue lives in a separate database, so a worker could pick the ping up before the AgentEvent row was visible and no-op the wake. Robustness of the ACP bridge: - pump_until now shares one deadline per turn (acp_turn_timeout, default 600s) via IO.select; a wedged agent is killed instead of wedging the bridge on a blocking read forever. - A replayed prompt after a respawn is prefixed with a recovery note so a half-completed first attempt doesn't get duplicated replies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make the first encounter one curl: served tools, setup branches, bridge flags A new agent meeting a CoPlan server previously needed a repo checkout to go live. Now the server hands over everything itself. - The agent scripts move into the engine (engine/agent_tools/) and every CoPlan deployment serves them read-only at /agent-tools/coplan-attach, /agent-tools/coplan_session.rb, and /agent-tools/coplan-bridge — whitelisted names only, rendered inline for the same X-Sendfile reason as the service worker, public for the same reason /agent-instructions is. Thin shims keep script/* working for local development. - /agent-instructions gains "Setup: Your First Five Minutes as a Live Agent": download the tools with curl, then follow the one branch that matches what your harness can do — background attach (exit is the wake), blocking attach, ACP bridge, webhook wake, or per-turn inbox drain. Verified end-to-end: fresh directory, two curls, one command, clean exit 64 on a quiet plan. - coplan-bridge learns flags so the simple path needs no config file: --acp "goose acp" --plan <id> --name Goose (plus --adapter/--session/ --base/--token/--approve). Flags win over the config file; base URL and token fall back to COPLAN_BASE/COPLAN_TOKEN. One deliberate behavior change: the adapter must now be named explicitly — the old silent default was "demo", which replies to threads and edits the plan, a rude surprise for anyone running a freshly downloaded script with only a --plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden the bridge's config surface and survive mount prefixes An adversarial review of the first-encounter bootstrap found seven real defects, all in the seams between flags, config files, and deployment shapes: - A leftover ~/.config/coplan/bridge.json silently steered flags-only runs: the file's base_url/token beat $COPLAN_BASE/$COPLAN_TOKEN, so a stale credential could claim sessions nobody asked for. Precedence is now flags > ENV > file, and the bridge announces which config file it loaded. Malformed JSON aborts naming the file instead of raising. - --plan unioned with the file's plan list instead of replacing it, so "watch this one plan" quietly watched the old ones too. - The adapter was validated at the first wake, not at startup: a typo'd --adapter claimed sessions, flipped pills active, then died hours later on the first event. Same for adapter acp with no command, and --acp "" (an unset shell var) turned every event into ack-and-lose. All four now abort before anything is claimed. - URI.join discards an engine mount prefix ("http://host/coplan" + "/api/v1/..." → "http://host/api/v1/..."), which broke every served script on prefix-mounted deployments — token minting 404'd silently and SSE aborted. All four scripts now concatenate. - Setup branch C rooted the ACP agent in ~/.coplan/bin via a chained cd; the bridge now takes --cwd, and the instructions say to run it from the directory the agent should work in. - Instructions referenced sections by names that don't exist and pointed at repo paths for revocation; both now reference the served /agent-tools/ scripts and real section titles. Verified end-to-end with a fixture stale config in a fake HOME: ENV wins, plans replace, the announcement prints, and all four startup aborts fire before any session is claimed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Lead the setup with curl, not with downloaded code The "first five minutes" section told a brand-new agent to download Ruby scripts from the server and execute them — backwards on two counts Hampton called out: Ruby is not on every machine, and agents should not be taught to blindly run network-fetched code. Flipped: the section now opens with the three HTTP calls the live loop actually is (claim, wait, ack), gives the branch-A wake as a seven-line shell loop any agent can port to its own runtime, and tells the agent to make the wiring durable — save it as a skill or saved command, a custom tool, or a standing ACP bridge config, so tomorrow's run attaches with one action instead of rediscovering the page. The served scripts drop to an optional convenience behind a read-before-you-run checklist scoped per script (attach + helper: this server only, writes only under ~/.coplan/$COPLAN_HOME, no subprocesses; the bridge: reads its config file and execs exactly the agent command you configured), with the download and the run in separate blocks so the paste-able unit never fetch-then-executes. An adversarial review of the rewrite (4 lenses, 18 findings) caught the checklist being literally false for the bridge, the example loop spinning silently forever on a dead token (now it exits loudly — a process exit is the wake either way), a literal-secret export in the one place the page otherwise keeps tokens out of command lines, and curl -O saving error pages as scripts (now -f, verified empirically). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Appease the newly CI-enforced RuboCop across the agent files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Absorb main's changed-sections struct and browse catchall ChangedSections now returns keys + rewritten (#192); the agent event payload carries that richer shape and the specs assert it. The URL catchall (#191) swallows the encoded-slash traversal probe before the agent-tools route sees it, so the spec now asserts the property that matters (no file leaves the server) plus a direct single-segment probe. agent-tools joins the reserved handles next to agent-instructions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Codex review: seven confirmed findings - Live updates broke on main's ChangedSections struct: the stream attribute now parses {keys, rewritten} (bare arrays still accepted), so viewer tabs render remote edits again; a rewrite arrives with no keys and stays flash-free by design. - The flash no longer scrolls: a remote edit must never move a reader who didn't ask to navigate. - The bridge acks per dispatched event and stops the batch on failure — the request-level ack was silently swallowing events whose harness dispatch raised, breaking at-least-once. - Status changes carry the acting token through CreateNotificationsJob into self-suppression: an agent that resolves a thread no longer wakes itself from its own status event. - Wake webhooks connect to the address the policy vetted (ipaddr pin, hostname kept for Host/SNI): resolving twice let a rebinding host answer the check publicly and the connection privately. - AgentEvent and AgentSession get ActiveAdmin registrations and ransackable allowlists (wake_secret deliberately excluded). - Plans and API tokens delete their collaboration rows (delete_all) instead of raising on the new FKs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.
Plans had ugly addresses and no home.
/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939atells you nothing, can't be read aloud, and can't be guessed. Worse, a plan could
live on any number of shelves at once, so "which library is this in?" had an answer
that depended on who was asking — which makes a canonical URL impossible. A document
with three locations has three addresses and no home.
This gives every plan one place and one address.
Browsable URLs
People are the root namespace. Every prefix is a real page, so trimming a segment off
any URL walks you up the tree. One action serves all three, because they're one thing
— a place in a library — and which of the three a path names isn't knowable until the
segments are resolved.
Resolution walks one segment at a time: handle, then folder slug within the previous
folder, then a plan slug in whatever folder we landed in. Nothing stores a joined
path, which is why renaming a folder can't invalidate anything beneath it.
Segments strip whatever the URL already says — the handle, every ancestor folder, the
plan type, the word "plan" — so you get
/sam/liveorder/cart-state-machineinsteadof
/sam/liveorder/liveorder-cart-state-machine-plan. Two plans whose titlesslugify the same way get a 4-char
~suffixon the leaf, collision-only, from analphabet with no
0/o/1/l.Unicode survives:
/aiko/operations/reliability/信頼性向上ロードマップ-2027年前半.Slugs normalize NFC and keep Unicode letters. Library handles stay ASCII, since a
handle is the root of every URL under it and gets typed and read aloud.
Everything that isn't a place lives under
_Handles sitting at the root means the app's own pages have to move out of the way:
That single character is the whole reservation. A handle can never be
_, becauseslug rules strip non-alphanumerics, so the split is structural rather than a list
someone has to remember to update.
_is reserved inside a library too (/sam/_/…,by the same mechanism), so library-scoped pages have somewhere to go later that can't
collide with a folder name.
Rails'
scope "_"prefixes paths and not helper names, sosettings_pathandpublish_plan_pathread the same at every call site as they did before.The legacy block near the bottom of the router is the one fixed list, frozen at the
paths that shipped before this —
/plans,/people/:id,/settings,/searchandthe rest all 301 onward. It can shrink; it never has to grow.
/apiand/agent-instructionsstay at the root: they're published external contracts, in everyAPI response and in
llms.txt.A person's page is their library
/people/:idrendered a read-only shelf./l/<handle>rendered a different read-onlyshelf. Neither had filters, folder counts, or "since you last looked" — which is what
made someone else's library feel like a lesser app rather than the same app, pointed
somewhere else.
Both are gone. Every library renders one page. What you can do to what's in it is a
question for the buttons —
Library#writable_by?, surfaced to the views as@can_write— not a question of which view to render. So browsing a colleague'slibrary gives you their folder tree, their filters, their counts, and what's changed
since you last looked, minus New folder and drag-to-file.
The identity that used to live on the profile page is now the header of that one page:
avatar, name, title · team, handle, and the directory link if the host provides one.
/people/:id301s onto it.Two consequences worth naming:
filing cabinet, so materializing it lazily was fine; it's a person's page now, and a
link to a colleague who has never signed in has to work. The migration backfills the
rows for everyone who predates this.
built out of
?folder=<id>against your own workspace, so clicking a folder insomeone else's library bounced you into yours.
Live libraries
Filing a document, moving it, retitling it, hiding it, creating or renaming a folder —
all of it changes what a library page shows, and none of it used to reach the page.
Now every one of those tells the library, and every browser watching that library
re-fetches. Readers too, not just the owner: a filter you're looking at is as stale as
one you could edit.
broadcast_refresh_torather than streamed fragments, because no fragment can beshared — visibility is per-viewer, the folder and filters are per-viewer, and the forms
carry per-session tokens. The broadcast carries no content, just the news that there is
some, and each browser re-renders its own page. With
turbo-refresh-method: morph+turbo-refresh-scroll: preserve, a page you're readingdoesn't blink, lose your scroll position, or close the popover you just opened because
someone else filed something. Turbo tags the stream with the acting request id, so the
browser that caused the change doesn't refresh twice.
Per library rather than per folder: a rename high in the tree changes every listing
beneath it, so a folder-grained stream would have to fan out to all of them.
One place per plan
has_many :placements→has_one :placement, with a unique index and a migrationthat collapses existing rows (keeping the author's own; oldest otherwise). Filing a
plan somewhere else is a move, never a second copy. The Save button that filed
someone else's document onto your own shelf is gone.
Plan#libraryis wherever it's filed, falling back to its author's library when itisn't filed at all. Every reader sees the same location, because there is only one.
Moving takes authority on both sides: write access to the destination, plus
authorship or write access to the library it sits in now. That second half is the
seam that will let a team reorganize its own library without letting anyone walk off
with someone else's document.
from_library_idon the Organize move ops becomes ano-op — filing somewhere new already takes it out of where it was.
Renames don't break links
Old URLs keep working through
coplan_url_aliases. Two kinds:renamed folder or handle. O(renames), not O(documents).
That table is a cache, not the record. Every rename is already in
plan_eventsand
library_eventswith before/after values, append-only, so the rows can berebuilt from scratch — which is what will make pruning safe later. Aliases are
consulted only after the real walk fails, so a live page always beats a stale
alias, and chained renames follow up to 5 hops (which also breaks any cycle bad data
could introduce).
/plans/<uuid>is no longer canonicalIt 301s onto the readable address — permanent, because the id form is the page's old
name, not a redirect-of-the-day — carrying the query string so
?thread=deep linksand the legacy
?tab=historyhop still land. HTML GETs only: a Turbo Frame fetch ora JSON caller asked for that exact URL and gets a response, not a hop.
Every link the app generates now points at the readable form directly — views,
controller redirects, search results, web-push payloads — so nobody pays for the
extra hop. The document page carries
rel=canonicalfor anything arriving the oldway. Path building lives in
Urls::Canonicalso jobs and push payloads can reach itwithout a view context.
Cross-document links read as documents again
Referenceonly knew/plans/<uuid>, so a link copied out of the address bar — whichis now every link — landed in the References footnote as a generic "link" with no
target_plan. It understands the readable form too, resolving through the same segmentwalk the router uses, aliases included: a link written before a rename still names the
document it was always about.
The catch is that
/plans/<uuid>was self-identifying and a readable path isn't./sam/liveorder/cart-roadmapis shaped like any other site's URL, so matching on shapealone would have typed half the links people paste as CoPlan documents. Classification
now takes the request host and only treats a readable path as ours when it is ours;
resolution (which plan) stays separate from classification (what kind of link),
because the extractor runs in a job with no request to ask.
Also in here
whether another plan held the slug, but resolution hands the segment to a
folder when both want it — so a plan sharing a sibling folder's slug had no
reachable address, and
/plans/<uuid>301'd onto the folder. Folders now countas siblings in both directions: a plan created beside a same-named folder takes
the
~suffix, and a folder created or renamed onto a plan's segment re-slugsthe plan it would have shadowed. Folders never take a suffix — a folder's
segment appears in every URL beneath it — so the plan is the one that moves.
ancestor of the requested path and not the path itself, so a folder rename
fixed every link into the folder except the link to the folder, and a renamed
library handle never matched at all (a one-segment path has no ancestors).
placement row by design, so counting placements called a library of nothing but
unfiled work "empty" while browsing it showed the plans.
/_/librariescould omit your own library. It read the table directly — exactlythe path that skips the materialize-on-first-touch invariant. Moot now that rows are
created eagerly, but the accessor is still what the index goes through.
transactional fixtures and clean up by hand; their lists predate libraries and
folders, so those rows accumulated across runs. A handle is globally unique, so a
leaked row kept
alicereserved. That leak is what had been hiding the bug above.Known gaps
on someone else's rename. The link keeps working (the alias catches it and the
address bar converges), but the text stays stale until someone edits it. Deliberate
— rewriting people's prose seems worse than a redirect.
eviction and the rebuild is possible from events, but neither job exists. Nothing
prunes today, so nothing breaks; the safety argument is currently theoretical.
per owner) and flagged in
Plans::Placeas the team-library seam.gets a new address and anyone following the old link lands on the folder that
now owns that name — a live page beats an alias, deliberately. The document
keeps an address instead of losing one, which is the trade I'd make again, but
it isn't a redirect to the same thing.
/. Mounted at asubpath it stops recognizing its own readable links rather than mis-recognizing
other people's — it degrades toward "generic link", never toward a false positive.
Testing
1715 examples, 0 failures, rubocop clean. All three migrations roll down and back up clean.
Dev seeds showcase the feature: a prefix alias (
sam/order-platform→sam/liveorder), a~gaxacollision suffix, a chained rename, Unicode titles inJapanese, Arabic, and Spanish, and a seeded cross-document link written the way an
agent would write it now — by readable address, resolved to a real reference.
🤖 Generated with Claude Code