* fix(cli): whoami says what a failed check established, and exits non-zero (BUG-2706)
whoami mapped EVERY failed identity check to a rejection ("PAD_TOKEN is
set but the server rejected it." / "Session expired.") and exited 0.
Measured on main: PAD_TOKEN set, server unreachable, "rejected", exit 0.
whoami is the safe-check scripts and agents run first, so a network blip
sent operators to rotate a healthy token, and exit 0 told scripts they
were signed in (chatgpt-review-seed.sh's `whoami || die` could never
fire).
- Only a 401 or 403 is a rejection (PAD_TOKEN: "rejected it (HTTP n)";
stored session: "session expired").
- Any other status: "could not check your credentials: <url> answered
HTTP n ... They were not rejected; try again".
- No answer or an undecodable body: "no usable answer from <url>".
- Every failure, and "not logged in", is now an error (exit 1).
- cli.StatusError: a non-2xx with a non-envelope body (a proxy page) used
to be a plain fmt error with the status only in its text. It keeps the
same Error() text and now carries the status; cli.HTTPStatus reads it
from either error type.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(cli): a 403 on whoami's stored session is a refusal, not an expiry (BUG-2706 codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809)
Several agents commonly share ONE account. A claim with no holder
defaulted to the account's email, so a second agent's claim matched the
first one's label and account and silently refreshed its lease: both
"won" (measured: two claims as rook and wren on one account both
answered 200 on the same lease).
- defaultLeaseHolder: the request's X-Pad-Agent name, else the email,
else the user id. An explicit holder still wins. The lease stays bound
to the account (BUG-3341). A second agent now gets 409 lease_held
naming the first, and cannot release it.
- next / ready (the dashboard's suggested_next) leave out items whose
live lease a claim by the caller would lose: same identity, the claim's
own predicate (store.ListForeignItemLeaseIDs, spelled positively
because NOT(...) over a NULL user compare is NULL), filtered before the
cap of three. Reminders are not filtered.
- Teaching: SKILL.md (claim before you start, pick another on
lease_held, release when done, register the session), instructions.md,
the pad_item holder param, and claim's CLI help, including that a
custom --holder's items leave your own next / ready.
- MCP ToolSurfaceVersion 0.76 (BEHAVIOR bump on the v0.70 grounds),
version.go entry, docs/mcp.md, README, golden bytes regenerated. The
holder description was trimmed to stay under the tools/list budget.
Remote /mcp sends no agent name, so there the default is still the email
(folded into BUG-2772). A lease taken before the upgrade is another
holder's to the same agent for up to its TTL.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(mcp): the catalog paragraph names v0.76 (TASK-1809 codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
.pad.toml's url, else the CLI's configured server (pad server info), else the MCP server's
own URL (Pad Cloud's MCP host maps to the app, a self-hosted /mcp to its base), else Pad Cloud.
Claude-Session: https://claude.ai/code/session_01J9famMX8HvArgW2uo8mPRT
When Claude Code is detected and the pad plugin is not installed, the
skill offer ends with the plugin's two install commands and what it
carries (the /pad skill plus the panel and live notifications, updating
itself). Installed is read from Claude Code's
plugins/installed_plugins.json (under CLAUDE_CONFIG_DIR when set); a
missing or unreadable file shows the hint. No auto-install yet.
Co-authored-by: Claude <noreply@anthropic.com>
In the docked pane (~400px on a 1440px screen) the balanced layout's
two-column fields grid stayed two columns, because it collapsed only on a
viewport media query, and its bare 1fr tracks let a select's and the tag
input's min-content push the grid to 476px: the right column ended at
x=1492 on a 1440px viewport.
.item-body.layout-balanced is now an inline-size query container and the
grid drops to one column below 560px of body width, with minmax(0, 1fr)
tracks. Measured: pane widths 312-461px one column with no overflow,
583px+ two columns, full page at 1440 two columns of 444px, mobile one
column; the 559.98/560 edge is clean; a BottomSheet opened inside still
covers the viewport.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
On a mature workspace "Needs Attention" and "Active Plans" rendered every
row (76 and 23 on the docapp workspace), burying Up Next and pushing
every later section screens down. Attention now shows its 6 most urgent
rows (overdue, blocked, needs a human, stalled, plan done, no parent)
and plans its first 5, each with a "Show all (N)" toggle that is
remembered per workspace in this browser. The API still returns the
full lists.
Co-authored-by: Claude <noreply@anthropic.com>
The page stepped `filteredItems` (updated order) while the list renders
each group in its sort mode's order and the table in its sort mode or
column sort. After an edit to an older item the two disagreed, and j
landed on the edited item instead of the row below. ListView and
TableView now report the ids they render (collapsed groups and drag
shadows excluded), and j/k step that order through listKeyNav, the way
the board steps BoardView's columns.
Co-authored-by: Claude <noreply@anthropic.com>
internal/server's -race step measured 1692s after #1885, 62.7% of its
45m cap, and the package ran almost entirely serially. Of the 30 slowest
serial tests:
- three (the *IsNilInProduction seam checks) built their store with
store.New(":memory:"), which runs the whole migration chain, about 17s
each under -race, to check one field. They now use testServer(t),
which copies the once-built template.
- 25 more call t.Parallel(). Each was checked with a go/ast pass over
the test and every same-package function it reaches: none writes a
package-level variable, sets the process environment, or listens on
a fixed port. The shared caches two of them use are mutex-guarded or
sync.Once.
- two stay serial: TestRateLimit_DisabledByEnv uses t.Setenv, which
t.Parallel refuses, and TestBUG3474_RevalErrorFailsClosedOnWrite
writes the package-level collabMembershipRevalInterval.
Co-authored-by: Claude <noreply@anthropic.com>
* docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489)
The initialize payload was 24,454 of its 24,576-byte budget, so the next
MCP change would trip TestMCPPromptBudgets. The multi_relation rules and
the relation title-matching detail are condensed to one line each, with
the full text moved to docs/mcp.md, and the resource list points at
resources/templates/list for descriptions. No rule an agent acts on is
dropped. Now 22,316 bytes (2,260 of headroom).
Co-Authored-By: Claude <noreply@anthropic.com>
* test(mcp): state the prompt-budget policy, a fixed ceiling with a trim rule (TASK-3489)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(plugin): the pane's Get started view: Pad Cloud, this machine, or your own server (PLAN-3490)
When the pane can't reach Pad it guides setup instead of showing an error: what this machine
has (the CLI, a linked folder), then three paths. Pad Cloud adds its MCP server to Claude Code
(claude mcp add, user scope) on a confirmed second press and fills /reload-plugins; it is never
listed in the plugin manifest, which would nag every session. This machine and your own server
hand the work to Claude in the prompt, so permission prompts apply.
Claude-Session: https://claude.ai/code/session_01J9famMX8HvArgW2uo8mPRT
* feat(plugin): Desktop: 6px of space between the pane's top line and the view tabs
Claude-Session: https://claude.ai/code/session_01J9famMX8HvArgW2uo8mPRT
* fix(web): an item load no longer waits for the index to catch up (BUG-3473)
ItemDetail awaited localIndex.bootstrap so wiki-links resolve against a
populated index (BUG-1461). On an index that is already ready with a
resync pending, bootstrap returns the reconcile, which reads
/items-changes until a page comes back empty; in a workspace others
are writing to, that chased new rows for seconds while the pane sat on
its skeleton. The call is still made (it schedules the catch-up) but is
awaited only when it is what populates the index.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(e2e): a doc this tab just created resolves as a link in a pane opened straight after (BUG-3473)
---------
Co-authored-by: Claude <noreply@anthropic.com>
CLAUDE.md was past Claude Code's 150k-character limit (163,658 on main
ee288cbe), so every session started with a warning. The API (53,364 bytes)
and MCP server (67,229 bytes) sections move byte-for-byte, including #1880's
v0.75 and field-filter lines; CLAUDE.md keeps an index and a hard pointer
for each, and says new notes go to the docs files. CLAUDE.md is now 46,162
characters.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
Folds the pad-mod pane (IDEA-3477) into plugin/ so one install gives the skills, the
monitor and the pane. plugin/hooks/register.js is the mod (Claude Code 2.1.287+; older
versions ignore it); plugin/tests run with `claude plugin test plugin` (9 pass).
The tool.call refresh can no longer disturb the call it watched.
Claude-Session: https://claude.ai/code/session_01J9famMX8HvArgW2uo8mPRT
internal/server's -race step ran at 94-97% of its 45m cap. Measured:
runtime.checkptr was 27% of its CPU, spent checking the unsafe
pointer arithmetic of modernc's transpiled SQLite, which -race enables.
The race steps now pass -gcflags='modernc.org/...=-d=checkptr=0': race
detection stays on everywhere and checkptr stays on for every other
package. -40% on a 40-test probe, -25% across 267 serial tests.
Co-authored-by: Claude <noreply@anthropic.com>
* fix: item lists refuse a filter key that names no field (BUG-3480)
The item list endpoints read every query parameter they did not know as
a field filter, so a parameter the server never had (the web Library
page's `all`, WebMCP's `collection`) answered 200 with an empty list.
A filter key must now be declared by a schema in scope (the
collection's, or any visible collection's on the workspace-wide list),
or be stored by an item in scope, which is honoured and named in the
X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI).
Otherwise 400 validation_error, "invalid list filter ...". A key that
is not a field key is refused too; the store used to drop it and
answer the unfiltered list. WebMCP pad_item:list now lists a
collection through its path. ToolSurfaceVersion 0.73.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: type the listByCollection mock so svelte-check can index its calls (BUG-3480)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: adopt pre-origin conventions and playbooks as built-ins, once, by exact title (TASK-3462 U4)
Items made before built-in origins were recorded get the origin their exact
title names (several entries sharing a title: the one whose body matches; no
title match: a playbook's invocation_slug), with no seed version, so they
read current or unknown_origin and get the 2-way preview (Dave's ruling: no
historical hashes).
It runs once per instance at server start, recorded in platform_settings,
and only for items created before the origin migration was applied: a later
item with no origin deliberately has none (a copy, an older import). Live
items in convention/playbook collections only. Non-fatal on failure.
Tests on both backends: exact title adopts, and an unedited copy then reads
current; an edited one reads unknown_origin; a renamed playbook adopts by
slug; soft-deleted and post-cutoff items are not adopted; a second run does
nothing. Each of the four mutants (cutoff, once, deleted, slug) is killed.
The matcher has unit tests, including ambiguous titles.
* fix: legacy built-in adoption counts the migration's own second, refuses a title/slug conflict, and reads only convention/playbook items (TASK-3462 U4, codex r1)
- The cutoff is at or before the migration's second (timestamps are
second-precision), parsed rather than compared as text, since imported items
keep their own format.
- A playbook whose title names one built-in and whose invocation_slug names
another is ambiguous and is not adopted.
- The pass lists the convention and playbook collections first and reads only
their items, instead of every originless item on the instance.
Both new tests failed on the previous code.
* fix: legacy built-in adoption requires the workspace to predate the cutoff too (TASK-3462 U4, codex r2)
Import keeps each item's created_at from the bundle, so an old bundle
imported after the origin migration but before the pass first ran read
as legacy. Every import door mints a new workspace and none writes into
an existing one, so the workspace's created_at bounds arrival.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(web): Library and Playbooks pages find their collections by trait, so a renamed conventions/playbooks collection still works (BUG-3481)
The web half of BUG-2702: both pages addressed the conventions and playbooks
collections by their default slugs, so a workspace that renamed either (a
documented onboarding step) got a Library page listing nothing Active with
Activate hidden, and a Playbooks page listing nothing with Create and Import
hidden. A small resolver (lib/collections/artifactSlug.ts) finds each by its
artifact_kind trait, as the server has since TASK-2657, falling back to the
default slug until the collection store holds the workspace; both pages key
their loads on it, so an unrenamed workspace still loads once.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): Conventions page and playbook editor by trait too; slug-aware stale guards; Playbooks page test (BUG-3481)
codex r1: the Conventions page and the playbook editor addressed the
collections by literal too; the Playbooks page's loads had no guard against
a superseded slug's response. All now resolve by the artifact_kind trait,
drop a response whose slug moved, and the editor ensures the collection
list before refusing a non-playbook.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(web): identity gates re-reviewed for the trait-resolved slugs (BUG-3481)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(web): renamed-collection coverage for the conventions create and the playbook editor check (BUG-3481)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test: pad library diff/update and the builtin_update capability (TASK-3462 U3c, red)
* feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c)
- diff shows what Pad's library changed since the item was made, what the
user changed (or a plain comparison when that is unknown), and the
settings an update would replace.
- update takes the library's text, guarded by the seq read just before. It
refuses content_pending_flush unless --overwrite-pending-edits, explains
update_conflict, and reports a current item without sending.
- The server advertises builtin_update; both commands refuse without it,
since an older server's bare 404 reads like not_builtin.
Not on MCP, per the design.
* test: pad library diff/update codex r1 findings (TASK-3462 U3c, red)
* fix(cli): pad library diff/update codex r1 findings (TASK-3462 U3c)
- The JSON diff carries the current text from a server that does not send it.
- A no-op under --format json is a JSON result.
- A failed capabilities probe says so; it is no longer reported as an older
server.
- An update prints the write's warnings: applied to an open editor, and
pending edits pruned.
- content_pending_flush is rendered by the shared helper, which names
set-aside rows apart from an open editor's; the flag's help covers both.
- The diff names an end-of-file newline change.
* test(cli): built-in diff keeps large numbers and refuses a mixed-version fallback (TASK-3462 U3c, codex r2, red)
* fix(cli): built-in diff keeps numbers as written and refuses a fallback from another version (TASK-3462 U3c, codex r2)
BuiltinText decodes its fields keeping numbers, and the fallback read of the
item does the same, with status and nulls dropped to match the server's
current. A fallback whose seq differs from the state's is refused instead of
mixed in.
* test(cli): built-in update reports undeclared fields (TASK-3462 U3c, codex r5, red)
* fix(cli): built-in update reports undeclared fields like any item update (TASK-3462 U3c, codex r5)
* feat(web): the Library page matches built-ins by key and badges an update on offer (TASK-3462 U3b)
GET /builtins joins the page's load. An entry is Active when an item made
from it exists (by key, so a rename no longer re-offers it), or by the old
title match for items with no recorded origin (legacy, until U4). An entry
with an update on offer shows 'Update available' (unedited copy) or
'Library changed' (edited), linking to the item. The identity gate rows
are re-reviewed, and builtinEntries is page data replaced on both arms.
* feat(web): the item pane offers a built-in's newer library text for review (TASK-3462 U3b)
BuiltinUpdateNotice reads GET /items/{ref}/builtin for items in a system
collection and, when an update is on offer, shows a badge that opens a review
dialog:
- an unedited copy: its text against the library's;
- an edited copy: what the library changed and what you changed, side by side;
- unknown origin: a plain two-way comparison that says it may have been edited.
It lists the settings accepting would replace and says only the body survives
in history.
Accept sends the previewed expected_seq. content_pending_flush turns the
button into an explicit discard; update_conflict refreshes the preview.
Nothing updates on its own.
The notice is fenced with identityFence(), joins the child-population guard,
and is mounted under the identity key with onUpdated refusing a stale key.
Behaviour tests: 8, with two mutants killed.
* fix(web): built-in update review never reloads over the pane, refuses with unsaved edits, and stays on its item (TASK-3462 U3b, codex r1)
- No callback into the pane after Accept. A loadData() there flushed a
pending raw draft over the accepted text, and cancelled the collab editor's
materialization flush. The item's own item_updated refreshes the pane, and
the notice re-reads when seq moves.
- Accept refuses while the pane holds unsaved edits (a pending raw draft, a
save in flight), asked synchronously at the press.
- A response for an item the pane has left commits nothing: the press checks
its ref, and the mount is keyed by item id.
- The dialog closes when its offer goes away, so it is never left empty.
- After content_pending_flush the copy says the discarded edits are not kept.
- The Library page offers unknown_origin too ("Library version differs").
The four new behaviour tests fail on the previous component.
* fix(web): built-in update review reloads raw mode only, waits for unsynced collab edits, and survives a rename (TASK-3462 U3b, codex r2)
- Raw mode keeps its body on an SSE refresh, so an accepted update now
reloads the pane there. The pane decides: not in collab mode, where a reload
cancels the editor's flush, and not over a pending raw draft. The callback
refuses a stale identity key (class-C table row).
- Unsaved edits now include local collab edits not yet flushed (localDirty)
and a collab provider that is not synced: edits the server's
content_pending_flush cannot see.
- A press tracks the item by id, so a rename during the request no longer
leaves Accept disabled.
* fix(web): built-in update guard uses the provider's sync state, not localDirty; a conflict refresh uses the live ref (TASK-3462 U3b, codex r3)
localDirty is not cleared when a collab flush is deduplicated, so it could
block Accept until a reload. A synced provider's typing is already in the
op-log, where the server's content_pending_flush refusal sees it; an unsynced
one (buffered pre-anchor, or dropped offline) is what the client must refuse.
After a rename, the update_conflict refresh now reads by the current ref.
* fix(web): Accept saves the open collab editor first and stops if that changed the text (TASK-3462 U3b, codex r4)
A synced socket proves nothing about typing the server has stored: a frame in
flight is not in the op-log, so content_pending_flush cannot see it. Accept
now runs a foreground editor flush first (new CollabFlusher.flushAndWait).
- flushed: the editor held text the preview did not show, so nothing is
replaced and the preview is read again;
- failed or skipped: nothing is replaced;
- deduped: the update proceeds.
The review dialog is a native modal, so no typing can follow the flush. The
not-synced refusal stays, for buffered frames that would land after the
update. Both new tests fail on the previous component.
* test(server): the built-in state carries the item's current text with its seq (TASK-3462 U3b, codex r5, red)
* fix: the built-in review compares against the item text read with its seq (TASK-3462 U3b, codex r5)
GET /items/{ref}/builtin now carries current: {content, fields}, the item's
own text from the same read as seq (additive, omitempty, present when an
update is on offer). The dialog compares against it, falling back to the
pane's copy for an older server. So after a 'flushed' stop, the re-read
shows the edits that caused it, and a fresh seq is never paired with a stale
body.
* fix(web): the built-in notice renders for any item the server says is built-in; document current (TASK-3462 U3b, codex r6)
The is_system gate missed built-ins moved, or activated, into a non-system
collection of the right kind: a Library badge then led to a page with no
review. The server's not_builtin answer now decides. CLAUDE.md's endpoint
bullet documents current.
* fix(web): the Library page's title fallback works again; the permission walk keeps an unactivated playbook (TASK-3462 U3b, CI)
The page listed conventions and playbooks with ?all=true. The server has no
such parameter and reads an unknown one as a field filter, so both lists came
back empty and the title match never matched. On main every library card
offers Activate, seeded ones included. Dropped.
U3b's key match then (correctly) marks all four template-seeded playbooks
Active, which left the permission walk's Library leg with nothing to
activate. Its setup now deletes one seeded playbook. It also creates a
convention with a library title and no origin (a legacy item), which the
leg asserts reads Active for every account that can see it. That assertion
failed with ?all=true and passes without it.
* feat: conventions checked on each comment alone, with the chip on its card (TASK-3119 U2b)
A new decision set, conventions_comments (QuestionSet.PerComment): the
conventions questions asked about each of an item's last 10 live comments,
one comment per state (BuildCommentState: the item's title and collection,
and the comment, 16k-rune cap), rows keyed conv:<ref>@<comment-id>. Gate 2
measured this subject: 7 of 7 comment-borne breaks caught, 0 of 9 false, a
credential past the old 2k trail clip caught at 0.99.
- Asked vs current (lead ruling): only the newest 10 comments are asked or
re-asked, but an answer stays current while its comment is unchanged,
however many newer comments arrive. Edited, deleted or moved: not current.
A title edit re-asks the window (the title is in the state, as measured).
- Idempotent per comment: a new comment costs one call, an item write none.
- Web: DecisionChips publishes per-comment chips from its existing read into
a small store keyed by item; TimelineCommentCard shows 'Possibly breaks
CONVE-N' on the comment (and on a reply) at p >= 0.9, full-reload link, no
compliance state. Cleared when the item goes.
- CLAUDE.md: the third set.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): a late decisions response cannot repopulate cleared comment chips (TASK-3119 U2b)
codex r1.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* chore(eval): TASK-3119 U2 gates in one command; decision.BuildCommentState
-gates runs three passes through the production question path:
A U0's population on the U2a item state (links in, trail out), gated on
precision not dropping below U0's on any convention that fired;
B comment-borne breaks, each comment asked alone with its item's title
and collection, gated on precision >= 0.9;
C a measurement of 12+ questions in one call (MaxPerCall 12).
BuildCommentState is the comment subject's one serialisation, shared by
the harness now and the runner in U2b. Comments are clipped at the body
bound (16k runes), not the trail's 2k.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat(decision): the conventions set asks about the item without its comment trail, 12 questions a call (TASK-3119 U2a)
The conventions state carried the last 10 comments, clipped at 2,000 runes
each: on docapp that was most of every call's input (15.7k tokens for one
write across 3 calls), 720 items had a clipped comment, and a break past the
clip was invisible anyway. Comments get their own subject in U2b.
- QuestionSet.NoTrail leaves the trail out; the conventions set sets it. The
state is exactly what the U2 gates measured (recent_trail [], links in):
precision held at 100% on every convention that fires, recall rose (1286
40->80%, 2693 70->90%), 0 truncated states, ~954 input tokens per call.
- MaxPerCall 5 -> 12, from measure C: up to 13 questions in one call gave
identical precision and recall, 0 of 116 answers moved by more than 0.2.
- A comment write no longer changes this set's state, so it costs no call.
- Every conventions answer computed before this reads not-current until the
item's next write (the state bytes changed): a fleet-wide re-ask, by design.
- CLAUDE.md: the registered sets (it still said attention was the only one,
since U1a) and the per-set state.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
Its contract is nil when the workspace is gone, an error when it was kept,
but the branch where its own soft-delete FAILED returned nil. Every caller
then reported a removal that had not happened while a live, ownerless
workspace held its slug.
- That branch now returns an error naming the failed removal.
- Cloud auto-create (both arms) logs that the workspace could NOT be removed
instead of leaving 'is being removed' as the last word.
- Create workspace's seed failure says in its 500 that the half-made
workspace survived and holds its name.
- addOwnerOrCompensate's ABSENT arm now returns the KEPT error, which its
callers already treat as a failure; the bundle keep path reads the row back
(BUG-2709) and the interrupted-import rollback does too (BUG-3475).
A test per caller with a SQLite trigger refusing the soft-delete; each fails
on the old nil.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): the import dialog shows progress, stops on a stall, and says what was kept (BUG-3475)
The dialog sat on 'Importing...' forever when the upload stalled: the upload
was a bare fetch, with no progress and no way to notice a stall.
- The upload goes over XMLHttpRequest (lib/api/importUpload.ts): bytes sent
of total, then 'Finishing the import...'. No upload progress for 60s, or
no response 120s after the last byte, ends it; 'Stop upload' and closing
the dialog cancel it. Both bounds are pinned against the server's 60s
per-Read idle window, read from the Go source.
- Each attempt carries a fresh import key (getRandomValues: randomUUID needs a
secure context, and http on a LAN IP is not one). When no response
arrives, the dialog asks GET /workspaces/import-status for up to 75s and
says exactly one of: finished (Open workspace), partial workspace kept
(Open workspace), nothing kept (Try again), or unknown (check your
workspace list). An unknown key is never 'nothing was kept'.
- A new open of the dialog ends any earlier operation, so a late outcome
cannot paint onto it; it is closed through the store too, which close()
never sees.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): a failed import upload is resolved through import-status (BUG-3475)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): import dialog fences Open workspace, closing stops the outcome poll, callbacks cannot wedge the upload (BUG-3475)
codex r1 on the web half.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): Open workspace does not navigate after the dialog was closed (BUG-3475)
codex r2 on the web half.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(server): the bootstrap counts built-ins with an update on offer (TASK-3462 U3a, red)
* feat(server): bootstrap carries builtin_updates, a count of built-ins with an update on offer (TASK-3462 U3a)
update_available plus diverged, among the items the caller sees in full:
the library listing's own visibility, through one shared helper. omitempty,
so a workspace with nothing on offer is byte-identical to before.
* feat(mcp): agents mention built-in updates once and never apply them; tool surface 0.71 (TASK-3462 U3a)
The skill (both copies) and the MCP server instructions describe the new
builtin_updates bootstrap count: mention it once per session, and leave each
update for a person to accept. ADDITIVE bump to 0.71, since the bootstrap
every door returns gained a member and the instructions changed. Golden wire
bytes regenerated: only the version and the instructions text moved.
* test(server): an accepted built-in update records the field values it replaced (TASK-3462 U3a, red)
* feat(server): an accepted built-in update names the field values it replaced (TASK-3462 U3a)
Version history keeps bodies only, so the summary of the update now lists
each non-body field whose value it changes, as key: old → new (JSON,
bounded), including typed convention metadata. Fields the update leaves
unchanged are not listed.
* test(server): every template seed and every library activation reads current at once (TASK-3462 U3a)
The guard the night-43 review ran by hand: the registry's hash must equal
what each seeding door stores. A seed that stores drifted text turns it red
(update_available straight after seeding).
* test(server): replaced-fields summary ignores numerically equal values and truncates on a rune boundary (TASK-3462 U3a, codex r1, red)
* fix(server): replaced-fields summary compares canonical numbers and cuts on a rune boundary (TASK-3462 U3a, codex r1)
* fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475)
The web import dialog hung on 'Importing...' when the upload stalled. The
server already bounds a stalled body (60s per-Read deadline, BUG-3184), but
it then KEPT the partial workspace and said so in a 400 that cannot reach a
client whose connection died; a lost 201 read as a failure whose retry made
a duplicate.
- A transport failure reading the body (deadline, reset, client gone) rolls
the partial workspace back and answers 400 import_interrupted with the
cause. A mid-stream DATA error still keeps it (TASK-896): its 400 reaches
a live client. Told apart by a tracker on the raw body, beneath the size
cap: a truncated file that arrives in full reads to a clean EOF.
- ?import_key= names an attempt; GET /workspaces/import-status?key= answers
running/complete/kept/removed/not_created/unknown for the caller's own key.
In memory, 1h TTL, bounded; an unknown key is UNKNOWN, never 'nothing was
created'. A running key cannot be reused (409).
- The validation-reject rollback and the new one share rollBackPartialImport.
The web dialog's progress, stall abort and outcome display follow in PR B.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(server): body-reader census counts the import's transport tracker (BUG-3475)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(mcp): import-status is a known static sibling of /{slug} (BUG-3475)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): a running import key is never evicted, and a panic records unknown (BUG-3475)
codex r1: eviction at the bound could drop a RUNNING entry, letting its key
begin again beside the upload still using it; it now drops only the oldest
settled entry, and an attempt over a bound full of running ones goes
untracked (reads unknown). A panic after mint skipped every report and left
the key running until the TTL; the handler now records unknown before
re-panicking, since the keep door does not say which way it went.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): a running import key outlives the settled TTL (BUG-3475)
codex r2: the TTL sweep expired RUNNING entries too, and an import may read
for up to its 1h ceiling, so a long upload's key could begin again beside it.
Running entries now last importOutcomeRunningTTL (2h), pinned above the read
ceiling.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): an import rollback reads back whether the workspace is gone (BUG-3475)
codex r3: removeUnusableWorkspace returns nil even when its delete fails, so
'removed' was inferred from a return that does not mean it. rollBackPartialImport
now reads the row back; a workspace still live reports unknown and the 400
says it could not be removed. Tested with a trigger refusing the soft-delete,
red without the read-back.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(server): a revalidation tick that errors leaves the collab conn writable (BUG-3474, red)
A real fault: workspace_members is renamed, so the tick's membership lookup
fails with a plain error. After the first failing tick, a frame still
persists. Red 3/3 on SQLite.
* fix(server): a collab revalidation tick that errors makes the conn read-only, not closed (BUG-3474)
Lead ruling: fail closed on WRITE, keep the connection. Both error branches
of the tick (GetItem, and a plain error from authorizeCollabAccess) now call
SetConnWritable(false) before logging. The next clean tick restores write if
the role still allows it. The test faults each branch for real (renamed
workspace_members, renamed items).
* test(server): a credential re-check store error keeps the collab conn writable; recovery is checked by content (BUG-3474, codex r1, red)
* fix(server): an unknown credential re-check makes the collab conn read-only and skips the tick (BUG-3474, codex r1)
streamCredentialStillValid reads a store error as valid (right for SSE,
which has no write), so the collab tick went on to set write from the role.
The collab loop now switches on credentialLiveness: invalid closes as
before; unknown sets read-only, logs at warn and re-arms.
* test(e2e): a list change while the pane follows j does not snap the cursor back (BUG-3204)
* fix(web): a list change while the pane follows j/k no longer snaps the cursor back (BUG-3204)
The collection route keeps the row highlight on the open pane's item: an
effect sets the cursor to ?item='s row whenever the list changes. j/k move the
cursor at once and the pane follows 140ms later, so a list change inside that
window snapped the cursor back to the old item, and the follow snapped it
forward again: a visible flicker, and attachment-viewer-owners' owner-2 flake
(CI: alpha, alpha, BRAVO, alpha after one j).
While a follow is pending the cursor is the newer intent, so the effect
yields; the follow's own ?item= change re-runs it.
Lever (e2e/bug-3204-cursor-snapback.spec.ts holds the /items-changes fetch a
rename triggers until j is pressed): main 10/10 fail, this 20/20 pass.
* fix(web): the cursor follows a reactive pending target, and a click supersedes a pending follow (BUG-3204, codex r1)
Codex r1: the first fix gated the snap-back on the non-reactive timer handle,
so a follow that ended without navigating (target deleted, already open)
left the cursor blank with nothing to re-run the effect, and a click during
the debounce was overridden by the follow.
pendingFollow {id, fromRef} is $state. While set, the snap-back targets that
row by id. It clears when ?item= reaches the row, when the follow opens
nothing, or when ?item= moves by another door, which also cancels the follow.
The spec freezes the page clock and holds the list fetch that carries the
change, so each case is deterministic. Main: case 1 (snap-back) and case 3
(click overridden) fail 10/10, and case 2 passes. Fix: 180/180 at 8 workers.
* fix(web): pendingFollow is per-session state; the follow timer keeps its identity fence first (BUG-3204)
The collection identity gate caught both: a new $state the identity reset
did not clear, and a timer whose identityHeld check had moved into a helper.
resetPerSessionState clears pendingFollow; the timer checks identityHeld
before anything else; the gate rows are re-reviewed.
* fix(web): a follow the controller would drop, or that never lands, releases its pending target (BUG-3204, codex r2)
follow() reports false when a pane navigation is in flight, since the
controller silently drops the open then. A follow that navigates arms a
fenced settle timer that drops a target still pending after 1s, covering a
goto superseded without ?item= moving. The identity gate's timer inventory
and rows are updated. Case 2 of the spec now claims only what it exercises.
* fix(web): the settle check shares the follow's timer handle, so teardown clears it (BUG-3204, codex r3)
The Settings button renders only once workspaceStore.current has loaded
({#if wsSlug && !isGuest}). The test waited for the footer row, which renders
earlier, and measured a row missing Settings when the workspace GET was slow.
Wait for the button the assertions are about.
Lever: delaying the workspace GET 2.5s failed the old wait 3/10 (Settings
absent, []) and passed the new one 10/10.
* test(e2e): task-3413 route handlers tolerate teardown closing the context mid-fetch (BUG-3471)
The route handlers fetch, read JSON and fulfil. When the test ends while one is
in flight, teardown closes the context and Playwright disposes the fetched
response, so apiResponse.json() throws 'Response has been disposed' and the
test fails after its assertions passed. Swallow only that abandoned-request
family; any other error still fails the test.
Lever: closing the context mid-handler fails the old handler 3/3 with CI's
exact error and passes the guarded one 3/3.
* test(e2e): guard the third task-3413 handler; tolerate only the two teardown messages (BUG-3471)
Codex: the long-name test's handler had the same fetch/json/fulfil race, and
'Route is already handled' means two handlers raced, a real defect, not an
abandoned request. Re-measured with the narrowed pattern: context close fails
the old handler 5/5 and passes the guarded one 5/5.
* fix(web): links to the /-/r/ resolver open their target (TASK-3119)
/-/r/{ws}/{ref} is a server redirect route with no client route behind it.
Three doors handed it to the SvelteKit router, which rendered it as
[username]=- / [workspace]=r: 'Could not load item / Workspace not found'.
- The 'Possibly breaks CONVE-N' chip: data-sveltekit-reload.
- Cross-workspace wiki-links rendered from markdown: rel="external" (already
on the sanitizer allowlist, so the allowlist does not widen).
- Editor link clicks: planHrefClick sends a resolver href to its full-load
branch instead of goto. Its old test pinned the goto.
Found by clicking the live chip on :7777. The new e2e clicks the chip and a
comment's wiki-link and asserts where the browser lands; both fail with the
fixes reverted.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(web): the resolver click-through e2e cleans up its fixture items (TASK-3119)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat(decision): log each evaluation's spend (TASK-3119)
One info line per evaluation that reached the provider: set, workspace,
item ref and id, questions, calls, input and output tokens, truncated,
outcome (stored|failed). Counts and identifiers only: no question, answer,
item text or error message. A failed call's tokens are counted too.
The dogfood box's /metrics is loopback-only and :7777 binds the LAN IP, so
the server log is where its spend is readable.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(decision): a liveness stop between split calls still logs the call made (TASK-3119)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat(web): "Possibly breaks CONVE-N" chips on items (TASK-3119 U1b)
The item page's decision chips gain the conventions set: a current
conv:<ref> answer at or above 0.9 is a chip reading "Possibly breaks
CONVE-N", linking to the convention through the ref resolver
(/-/r/{ws}/{ref}, so a renamed conventions collection still resolves).
The U0 eval measured the check as precise but shy, so a missing chip
means nothing: no "complies" state, no green, and an answer below the
threshold produces no chip at all. Same fetch, same switched-item and
identity fences as the attention chips.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): convention chip title makes no compliance claim (TASK-3119 U1b)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* wip(decision): conventions set (TASK-3119 U1a)
* chore(eval): executable-conventions eval through the production question path (TASK-3119 U0)
Measures, before any chip exists, how often the decision provider says a
hand-labelled item breaks a convention at the 0.9 threshold: per
convention precision and recall (all cases and real-only), the false
'Breaks' rate of a behaviour convention, and the calls and tokens spent.
State is decision.BuildItemState, the question a decision.Noul worded as
the U1 contract says, the model decision.DefaultModel.
The hand labels were posted to TASK-3119 before any score existed; the
trail comment carrying them is dropped from every state, and the dry run
refuses a state that still carries them. Reads TYPESAFE_API_KEY only.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* chore(eval): -links and -only for the conventions eval (TASK-3119 U1a)
-links builds each state as the conventions set does, with the item's
links (from the dump: the parent link only; blocking links are not in
pad item show's JSON). -only runs one convention. Used for the lead's
U1a gate: CONVE-1286 rerun with links.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(decision): liveness before EACH split call (TASK-3119 U1a, codex r1)
A split set makes several provider calls; the liveness check ran once
before them, so an item or workspace deleted between calls was still
sent in the later ones. It now runs immediately before every call. A
test deletes the item from inside the first call and the second is not
sent; checking only once fails it.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(decision): decision_check has no default; allow-list the eval's decode (TASK-3119 U1a)
A default "on" was written into every new convention: imports gained a
decision_check field (artifact import parity) and an activated built-in
no longer matched its seed (TASK-3462 would read every one as diverged).
Absent already reads as on, so the field carries no default, in the
template and both migrations.
The conventions eval's readJSON is a raw decode the reserved-field guard
counts; allow-listed with its reason, like the attention harness.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
The copy preflight decoded carried field values with plain json.Unmarshal
into `any`, so a number above 2^53 became a float64 and the dry run
previewed 9007199254740992 for a stored 9007199254740993. The copy itself
keeps the stored digits (server-side), so the preview misled exactly
where a user would check. Decode with models.DecodeJSONKeepingNumbers,
as BUG-3448 did for item show and the MCP resource.
The mutating copy's response is left on json.Unmarshal: it prints no
carried values (the item's fields are a string), and a decode error
there would misreport a committed copy.
Tests: the client keeps the digits, and the rendered dry run through the
real client shows them; on the old decode it renders
"= 9007199254740992".
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test: pad init keeps an edited skill and does not downgrade a newer one (BUG-3466)
* fix: pad keeps an edited /pad skill and never downgrades a newer one; skill files carry a stamp, and --force replaces (BUG-3466)
* test: workspace init updates an older unedited skill, agent update reports a keep, --list names --force for a newer skill, and semver numbers have no size limit (BUG-3466, codex r1)
* fix: workspace init routes installed skills through the decision, agent update and --list report kept files with --force, and semver numbers compare at any size (BUG-3466, codex r1)
* test: --list flags an untracked edited skill, and agent update counts an untracked current one (BUG-3466, codex r2)
* fix: --list asks the decision for local skills, and every skill present after a write is recorded and counted (BUG-3466, codex r2)
* test: invalid SemVer never refuses as a downgrade; the frozen list pins a released text (BUG-3466, codex r3)
* fix: the frozen legacy list holds released output only, and SemVer is validated strictly before comparing (BUG-3466, codex r3)
* test: every unstamped release's skill output must be in the frozen list; the release workflow (which has the tags) fails loudly otherwise (BUG-3466, lead)
* test: whitespace after the stamp does not hide it; pinned real copies and per-tag formatter checks guard the frozen list independently (BUG-3466, codex r5)
* fix: whitespace after the stamp no longer hides it (BUG-3466, codex r5)
* test: a newer pad's identical text reads as current, not as a downgrade (BUG-3466, codex r6 declined with reason)
* chore: regenerate the legacy skill hashes with v0.18.0-rc.8 (BUG-3466)
The billing page offers Monthly ($8 / month) and Annual ($80 / year),
with a local-currency note, above both Upgrade buttons, sharing one
choice. Monthly is the default. The checkout sends {interval}.
The page opens Stripe only for a session priced at the picked interval
(decideCheckout, against the sidecar's echo, pad-cloud #62). A missing
echo means a sidecar from before TASK-3367, which sells monthly: a
monthly pick still proceeds, an annual pick is refused. A differing echo
is refused. A 503 for the interval shows a plain message, disables that
option and moves the choice to the other; other failures stay toasts.
The picker shows prices, so it renders only under commerceAllowed and
joins noCommerceOutsideGate's gated list; that guard also gains a
price-copy pattern, so a price outside the gate fails it.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): Active Plans rows hold a long title at desktop through 375px in both themes (BUG-3440)
* fix(web): a long Active Plans title clips with an ellipsis and its full text on hover, and takes its own line on a phone (BUG-3440)
A moved-in backlog of open tasks and bugs with no priority set, bugs in
their initial status `new`, answered "No ready items found": only the
literal status `open` counted, and an open item outside an active plan
needed a high/critical priority (BUG-1082's "without flooding", though the
list is capped at three).
Such items now fill the slots the existing tiers leave empty, from a
fallback tier below every item admitted before, so a workspace that
filled three slots gets the same list. The tier admits items whose
collection is not a system collection and declares `priority` or
`severity` (lead-ruled; across every built-in template that is Tasks,
Features, Backlog and Bugs), whose done field is not terminal, that are
not in progress and not blocked. It ranks by priority, else severity,
then age.
`ready`'s help and the MCP `ready` description said it returns the full
actionable backlog; it returns the same capped top three as `next`, and
now says so. The empty state says why and points at `pad project stale`.
ListCollectionsMinimal also returns is_system.
ToolSurfaceVersion 0.70 (behavior bump on the v0.49 grounds).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test: onboarding an existing project asks where its open work lives and brings it in after saying there is no importer and confirming (TASK-3454)
* feat: the onboard playbook asks where a project's open work lives and offers to bring it in, saying there is no importer and confirming first (TASK-3454)
* test: open work is untrusted data, its destination is confirmed when B2 did not run, and the paste fallback does not assume gh (TASK-3454, codex r1)
* fix: open-work text is data not instructions; the destination is confirmed when B2 did not run; the paste fallback is keyed on being able to list issues (TASK-3454, codex r1)
* test: a full page of issues is reported as possibly truncated (TASK-3454, codex r2)
* fix: a full page of issues is reported as possibly truncated rather than silently cut (TASK-3454, codex r2)
* fix(web): quick-add is `c`, and one switch turns every single-key shortcut off (BUG-3465)
Mod+N reaches a page in no browser tab: Chromium reserves IDC_NEW_WINDOW
(IsReservedCommandOrKey), Firefox declares accel+N reserved="true", and
Safari reportedly does not dispatch it. Quick-add moves to `c` (Linear,
GitHub and Jira's create key), outside editable fields and with no
modifier held. Mod+N stays bound for installed-app windows but is no
longer advertised.
WCAG 2.1.4 needs a way to turn character-only shortcuts off, and the app
already shipped `?` and j/k/h/l without one. characterKey() is now the
single gate: `c`, `?`, j/k/h/l (mapped onto the arrow cases, so Ctrl+J
no longer moves the list or swallows the browser's Downloads key) and
the image viewer's + = - 0 read their key through it. A per-device
"Single-key shortcuts" switch sits in the shortcuts modal and on the
account settings page (the way back once `?` is off).
Shortcut labels follow the platform: ⌘ on macOS, Ctrl elsewhere, in the
modal and every tooltip.
A source census fails on any raw single-character key comparison outside
characterKey(), with a reasoned allow-list for focus-scoped input
triggers; it is red on the unconverted bindings.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): the palette's dark-theme axe scan waits for the theme's transitions (BUG-3465)
The scan ran the instant data-theme flipped, while the filter chips were
still fading their colours (`transition: all 0.15s`), so axe read
in-between values: #9b9baa on #3b3b44, 4.04:1, failing #1851's CI twice
while passing 13/13 locally. Neither colour belongs to either theme.
The scan now waits until no CSS transition is running. With every
transition stretched to 2s, the old order fails with CI's assertion and
the new one passes.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS