mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
8cdeeb166b32a682b9fb4df5f5facbad1177cf6e
1336 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8cdeeb166b |
fix(store): orphan GC claim protocol — writer reference stamps + conditional row-first deletes (BUG-2415) (#1129)
* fix(store): orphan GC claim protocol — writer reference stamps + conditional row-first deletes (BUG-2415) The sweep scanned content for pad-attachment: references, then deleted the BLOB, then the row — with nothing serializing it against content writers. A reference committing between scan and reclaim left either a dangling id or, worse, a surviving row whose bytes were already gone. Claim protocol: - attachments.last_referenced_at (dual-dialect migration): every content writer that persists a pad-attachment: reference stamps the rows INSIDE its own write transaction (stampAttachmentRefsTx), wired at the four store chokepoints every surface funnels through — CreateItem, the UpdateItem core (item PATCH, collab-snapshot flush, version restore, bulk update), CreateComment, UpdateComment (both now transactional). Workspace-scoped; covers content AND fields, matching AttachmentReferenced's scan surface. - The sweep's row deletion is now the atomic claim: a conditional DELETE re-asserting reclaimable state in the statement itself (ClaimNeverAttachedAttachment: unattached + live + no fresh stamp; ClaimSoftDeletedAttachment: still deleted + still past grace, so a mid-sweep restore survives too). Writer stamp and claim serialize at the database; whichever commits first wins and the loser observes it. - Row BEFORE bytes: the blob is reclaimed only after a successful claim, so a surviving row implies surviving bytes — the old order's worst failure mode (row without content) is structurally impossible. - orphanGCRefStaleWindow (15m) is documented as a correctness parameter: the stamp only covers references landing after the scan, so the window bounds scan-to-claim latency plus a maximally stalled writer transaction — not a lease on long-lived references (the LIKE scan still guards those). Sweep-level test pins the filed race (fresh stamp survives sweep, row AND blob) with a counterfactual arm (aged stamp reclaims); verified discriminating against a compiling control build of the old sweep order. Store tests cover every claim predicate leg, stamp wiring on all four chokepoints, and workspace scoping. * fixup: codex round 1 — stamp move-override + workspace-import paths, parent-aware variant protection (scan by parent id + claim NOT EXISTS fresh parent stamp), variant test with total-loss control * fixup: codex round 3 — stamps ordered BEFORE content statements (PG row-lock makes the claim wait out the writer tx), chunked stamp IN-lists, BlobDeleteFailures counter * fixup: codex round 4 — stamp variants of referenced originals (own-row lock protects concurrently-claimed thumbnails), bounded-duration residual + irrevocability docs |
||
|
|
54526c5b33 |
fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) (#1128)
* fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) Seven sites assigned ItemDetail's collection snapshot under fences that ordered STARTS, and loadData's cross-collection escape hatch admitted any generation-stale write that fetched a different collection — so a loadData continuation spanning a cross-collection MOVE restored the SOURCE collection over the freshly adopted TARGET (the live item, itemGen-fenced, kept the move: the pane rendered the item against the wrong collection's schema). All writes now route through adoptCollection, backed by the pure shouldAdoptCollection decision: (1) a snapshot disagreeing with the LIVE item's collection_id is vetoed regardless of freshness — id, not slug, so renames still apply and a reused slug can't satisfy it (this also closes a latent foreign-write in the SSE collection_updated refresh, which fetched by slug); (2) same-collection refreshes keep newest-started-wins; (3) the legitimate cross-collection correction the old hatch existed for still lands when the live item agrees. On the veto path, an embedded pane whose collection was still null (fresh mount — refreshCollectionIfMoved's !collection guard skips there too) converges on the live item's collection instead of being left schema-less (adoptOrConvergeToLiveCollection); non-embedded masters stay route-authoritative per the existing policy. e2e reproduces the filed race deterministically (route-hold on the realColl fetch, API move mid-hold, release): the control build renders the moved item against SrcMarkerField's schema verbatim; the fixed build converges on the target's. * fixup: codex round 1 — post-converge myGen re-checks, schema-less error surfacing, pre-fetch convergeGen, hard collection_id oracle in e2e * fixup: codex round 3 — empty-string collection_id normalizes to no-anchor, else-branch schema-less surfacing, itemGen re-check before singleton claims * fixup: two stale comments (codex round 5 docs) |
||
|
|
904878522a |
fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) (#1127)
* fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) Two stranding layers, both from the same root: a collection rename changes the slug without touching items, so nothing item-shaped ever re-announces it. 1. ROUTE: delta-sync catch-up covers item changes only, and /changes says nothing about renames — a rename-only gap even reports caught_up — so a client that missed the collection_updated SSE (replay gap, disconnect) kept a dead route slug; slug-keyed fetches 404'd until a manual navigation. The collection route now reconciles its slug against the live collections list by STABLE collection id on every sync pass (resolveSyncRenameTarget — pure, unit-tested — wired via reconcileRouteCollectionSlug), mirroring the BUG-2272 SSE/reorder-404 heals and sharing the renameNav intent tracker. 2. DATA (discovered by this fix's own e2e, present on the LIVE SSE path too): cached localIndex rows keep the old collection_slug — rows only re-stamp when the item itself changes — so EVERY rename-healed route rendered an empty board while the sidebar counted the items. New localIndex.retagCollection re-stamps rows by stable collection id (search + IDB write-through, upsert pattern), called from the workspace layout's global collection_updated subscriber (any route, live SSE) and from the sync heal (missed SSE, where the layout subscriber also missed the event). e2e covers both paths with an aborted-SSE missed-event leg and a live SSE leg; both specs fail on the pre-fix build (verified) and the missed-SSE spec guards its own vacuity (asserts the strand before triggering the heal). * fixup: codex round 1 — foreign-snapshot gate on sync heal, pendingRetags for pre-hydration renames, layout loadCollections widened, goto-failure renameNav reset, worker-unique e2e slugs * fixup: stamp pendingRetags with recording user's identity; discard on mismatch at warm-hydrate apply (codex round 2) * fixup: heal the full-page item route's collection segment on sync pass too (codex round 3 — the bug body's own example route) * fixup: reconcileCollectionSegment switch-safety — pre-await fence, destroyed guard, identity-compared bridge cleanup (codex round 4) * fixup: compare the read-back $state proxy, not the raw literal (codex round 5) * fixup: codex round 7 — navigating guard on both healers, replaceState on the list heal, null-owner adoption for pendingRetags, in-window vacuity pins in e2e |
||
|
|
f0cbcb5df4 |
fix(mcp): route backlinks/history/report over HTTP transport + catalog↔route parity test (BUG-2304) (#1126)
* fix(mcp): route backlinks/history/report over HTTP transport + catalog↔route parity test (BUG-2304) Three catalog actions were advertised on the remote /mcp transport but had no route: pad_item.backlinks, pad_item.history, pad_project.report all answered 'not yet implemented over HTTP transport' on Pad Cloud. All three have working REST endpoints — the gap was mappers, and the absence of any catalog↔routeTable parity check is how they shipped silently. - item backlinks + project report: plain routeSpecs (their CLI JSON is the endpoint response verbatim, so a GET reproduces the stdio shape). - item history: hand-written dispatchItemHistory, because the versions endpoint returns full content bodies while the CLI projects to the token-light itemVersionSummary; full=true opts back in, matching the stdio --full path. - The special-case dispatch switch is now an introspectable specialRoutes() map, and a new parity test drives EVERY catalog action through its real ActionFn, captures the dispatched cmdPath, and fails on any action missing from routeTable ∪ specialRoutes ∪ itemLinkSpecs — or any action the fixture can no longer exercise. No ToolSurfaceVersion bump: no tool names, action enums, or parameter shapes changed — advertised actions now work as documented. Also folds in CLAUDE.md catalog-version drift (still said v0.19; 0.20 shipped in BUG-2302/2305). * fixup: cache specialRoutes map (sync.Once); kind-aware item_not_found on history 404 (codex round 1) * fixup: single request path for history — full=true keeps the kind-aware 404 envelope (codex round 2) * fixup: version.go no-bump changelog note, instructions full:true note, parity-test scope comment (codex round 3) |
||
|
|
f756e853fe |
fix(oauth): keep zero-workspace consent authorizable via the wildcard path (BUG-2303) (#1124)
The consent template gated the whole workspace fieldset on the user having memberships; with zero workspaces no access radio rendered and the inline script permanently disabled Authorize — a dead end, even though parseConsentPayload's wildcard path accepts a zero-workspace workspace_access=all consent with no membership validation. Render the 'All my workspaces' radio unconditionally (force-checked when memberships are zero — it is the only option, and an unchecked radio group would re-disable the button), keep the specific radio + picker gated on memberships, and replace the dead-end copy with a pointer at the workspace-creation checkbox so the client can create the user's first workspace. |
||
|
|
94441b4eb2 |
chore(nix): bump package version to 0.14.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dtv0.14.0-rc.1 v0.14.0 |
||
|
|
cada8777e7 |
fix(web): full-page pane host gets its own navigate-away handling (BUG-2178) (#1123)
The full-page item host reused the shared controller's handlePaneNavigateAway, whose goto targets are collection-host-shaped (the base route IS the collection page). On this host both emits abandoned the master: a pane collection-rename landed on the renamed collection's root page, and a pane item-move hard-navigated to the moved item's full page. The host now wires planFullPageNavigateAway (new pure planner in paneController.ts, next to planPaneDrill and friends): - COLLECTION RENAME (keeps-pane emit): IGNORE. Nothing the host owns is invalidated by the emit alone — if the renamed collection is the MASTER's, the master ItemDetail's own BUG-2272 SSE rename handler already gotos the new-slug URL with the full search string (?item= included), so the route self-heals with the pane intact; a foreign collection never touched the master route. The embedded pane needs no URL change either — it trusts item.collection_slug. - ITEM MOVE (no ?item=): RETARGET the pane to the moved item's slug via the existing drill machinery (navigatePaneTo), which preserves the master pathname by construction and handles depth/ownership/ focus. When ?item= already held the slug (a same-workspace move keeps slugs, so the drill same-ref-guards to a noop) the pane self-heals via the item_updated SSE refetch instead. - Malformed/pathless URLs: IGNORE — staying on the master beats navigating somewhere unparseable. (decodeURIComponent throws on malformed percent sequences; the hostile-input unit test caught that crash before it shipped.) The controller's handlePaneNavigateAway is annotated collection-host- only; every property of its spec comment is untouched for that host. Tests: - planFullPageNavigateAway unit table (both real emit shapes verbatim, encoded slugs, trailing slash, malformed/empty/foreign-origin). - E2E (pane-full-page-capstone.spec.ts): move the PANE item to another collection from the docked pane; assert the pathname never leaves the master route and ?item= retargets to the moved slug. Mutation- verified against a control binary with the old wiring: it fails with ?item= gone and the master abandoned — the reported bug, verbatim. The spec header's BUG-2178 deferred note updated to covered. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
900b0c428a |
fix(mcp): summary-shaped item list on the HTTP transport (BUG-2305) (#1122)
A bare pad_item.list over remote /mcp returned up to 50 items with
FULL content bodies — the exact token blowup TASK-2000's limit was
written to prevent, unmitigated on the transport the zero-CLI plugin
makes primary. The limit was symmetric (actionItemList injects it
before dispatch); the SHAPE was not: the exec path projects through
cli.ToItemSummaries in the CLI, while the HTTP path forwarded the raw
handler response.
Fix follows the trail's pre-committed approach (the body's "add a
summary param to mapItemList" is refuted there — the server has no
projection parameter and a RouteMapper has no response hook): a
hand-written dispatchItemList, same shape and same cli.ToItemSummaries
projection as HTTPResourceFetcher.fetchItemList. mapItemList stays the
single URL/filter builder; the routeTable entry is removed so the
hand-written method is the one path. full:true opts into complete
bodies on both transports (stdio forwards it as the CLI's --full).
Audited the other list-shaped actions per the body's ask: pad_search
is symmetric (the STORE zeroes Item.Content in search results —
internal/store/search.go); pad_project.activity returns enrichment
metadata, no content bodies, same endpoint on both transports. Only
item list had the asymmetry.
Also rewrites the misleading actionItemList comment ("summary vs full
is a CLI-side concern") that misdescribed the HTTP path.
Old scope/verified-email fixtures stubbed `{"items":[]}` — an object
shape the real endpoint never returns (it writes a bare array); they
only passed because the routeTable path packaged blindly. Fixtures
corrected to the real shape.
Tests: TestHTTPItemList_DefaultIsSummaryShape +
TestHTTPItemList_FullOptsIntoCompleteBodies drive the REAL server +
store as a counterfactual pair — the marker sits past the
content_preview cut, the full leg proves it flows through the same
path, the default leg proves the projection strips it.
Mutation-verified: removing the dispatch case fails (route gone);
skipping the projection fails (leak caught).
No ToolSurfaceVersion bump here: BUG-2302's PR carries the single
0.19→0.20 bump; this PR appends its lines to that changelog entry
after it lands (lead's sequencing ruling — every changelog sentence
true at its own merge time).
Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
|
||
|
|
727cd80927 |
fix(mcp): explicit tool annotations from catalog write-shape knowledge (BUG-2302) (#1121)
mcp-go's NewTool injects default annotations on every tool — ReadOnlyHint:false, DestructiveHint:true, OpenWorldHint:true — and buildToolFromDef never overrode them, so every Pad tool advertised itself as destructive, including pure reads like pad_search and pad_project. Hosts use destructiveHint to decide whether to prompt; mislabeling reads trains users to click through prompts. Derive the block in buildToolFromDef from the catalog's own knowledge (readOnlyActions — the same single source the tool-surface serializer uses — plus a new sibling additiveWriteActions allowlist): - every action read-only → ReadOnlyHint:true, DestructiveHint:false, IdempotentHint:true (pad_search, pad_project, pad_attachment, pad_meta, pad_playbook); - writes all purely ADDITIVE → ReadOnlyHint:false, DestructiveHint:false (pad_workspace: invite/create/claim/restore; pad_library: activate — codex round 1: marking additive writes destructive reintroduces the prompt-training harm at tool level); - any overwrite/delete-capable action → the conservative ReadOnlyHint:false, DestructiveHint:true (pad_item, pad_collection, pad_role) — unchanged on the wire from the old defaults; - OpenWorldHint:false everywhere (pad tools are closed-world). pad_set_workspace gets a hand-written block (write, non-destructive, idempotent, closed-world). Also adds the missing pad_item.history entry to readOnlyActions — documented read-only since v0.14 but reported read_only:false on the tool-surface descriptor. ToolSurfaceVersion 0.19 → 0.20 (behavior bump, v0.9/v0.16 precedent): no tool names, action enums, or param shapes changed. instructions.md and README headings retitled per the drift tests. The changelog entry describes only this change; BUG-2305 appends to it if it ships in the same window (one bump total). Tests: TestCatalogTools_AnnotationsExplicit pins a literal per-tool read/additive/destructive table (deliberate second enumeration — a new tool, or a write action added to an all-read or all-additive tool, fails loudly until someone decides its class); TestAdditiveWriteActions_NoStaleEntries guards the new allowlist (real catalog pairs only, never overlapping readOnlyActions); TestSetWorkspaceTool_AnnotationsExplicit covers both deployment variants; pad_item.history joins the read spot-checks. Mutation-verified both directions: destructive-polarity flip fails 10 tools; always-destructive fails the two additive rows. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
aa33dc407e |
fix(server): serve RFC 9728 PRM at path-aware well-known (BUG-2266) (#1120)
A client configured with the path-suffixed transport URL (https://mcp.getpad.dev/mcp — the shape every FastMCP example uses) constructs its protected-resource-metadata URL per RFC 9728 §3.1 by inserting the well-known segment before the path: /.well-known/oauth-protected-resource/mcp. Pad only registered the exact-match root route, so that request fell through to the SPA catch-all and OAuth discovery died JSON-parsing HTML (Kimi CLI / FastMCP 3.2.4). Register the path-aware route for the two shapes a pasted transport URL actually produces (/mcp and trailing-slash /mcp/), serving the identical canonical document. Bounded rather than a wildcard: the handler emits Cache-Control public max-age, and a wildcard would hand a CDN one cacheable object per attacker-chosen suffix (codex round 2). Deliberately NOT touched: NormalizeAudience / audienceMatchingStrategy (the body's "secondary" fix) — shared by the AS-side strategy and the RS-side token check; widening it is a separate security-boundary item. For the same reason the suffixed doc keeps the canonical bare-host `resource`: echoing .../mcp would steer compliant clients into an audience the AS still rejects (codex round 1, declined — doc-following clients converge on the canonical audience and work end-to-end). Test: TestMCP_DiscoveryDoc_PathAwareWellKnown decodes both suffixed variants into the typed doc and compares field-by-field against the root response (SPA HTML cannot satisfy it), pins that an arbitrary suffix does NOT get the doc, and the path-aware URL joins the cloud-mode-off 404 list. Mutation-verified: with the route lines removed the test fails 404. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
9c155ac185 |
fix(cli): gate promptAndBootstrap on canPromptForConfig() (BUG-2597) (#1119)
Third member of the BUG-2577 family (offerSkillInstall #1111, installInteractive #1116): promptAndBootstrap — the legacy --cli-prompt admin bootstrap — guarded its prompts on stdin-only term.IsTerminal, so a pty-backed harness with a redirected stdout got " Email: " printed into the pipe and then blocked on the read. Swap to canPromptForConfig() (stdin AND stdout) with the family's boundary comment; the BUG-988 refuse-with-headless-hint behavior is unchanged. The error message no longer blames stdin specifically ("not running in an interactive terminal") since the widened gate can fire when stdin IS a terminal and stdout isn't; the existing non-TTY test's assertion updated to match. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
d843752091 |
docs: worktree web-tooling rules in CLAUDE.md; fix vitest.config.ts's dangling pointer (TASK-2590) (#1118)
CLAUDE.md gains the "Working in a git worktree" section that web/vitest.config.ts:41 has pointed at since the fs.allow fix — it never existed (grep worktree/npm ci/node_modules: zero hits). Content per the corrected day-38 ruling on TASK-2590, not the task's original body: the symlink stays fine and stays the recommendation; the real prerequisite is `npx svelte-kit sync` (a fresh worktree has no generated web/.svelte-kit, and vitest fails on the missing tsconfig either way — the 2x2 on the trail shows the symlink was never the variable); and npm ci through a symlinked node_modules is the one genuinely destructive move (deletes the shared tree, stalls every session), which the original "npm ci, never symlink" rule would have instructed agents to do. Both documented legs verified as written in this very worktree: fresh + symlink -> vitest fails with the exact quoted TSCONFIG_ERROR; npx svelte-kit sync -> same test passes through the symlink (and through this edited config file). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
3098a1f569 |
fix(web): search Enter go-to accepts full refs like TASK-1345 (BUG-2128) (#1117)
* fix(web): search Enter go-to accepts full refs like TASK-1345 (BUG-2128) The palette's Enter fast-path only matched bare digits (/^\d+$/, BUG-910), so typing a full ref + Enter fell through to the arrow-selection guard and did nothing. Extract the routing decision as parseGoToTarget() beside REF_PATTERN_RE (pure, unit-tested): bare number keeps its match-any-collection semantics; a PREFIX-N ref (case-insensitive) must match prefix AND number via formatItemRef, so a typo'd prefix is an honest no-op rather than a cross-collection jump, and TASK-007 deliberately matches nothing rather than guessing TASK-7. The server-search fallback queries the bare number for both forms — the query shape the item_number path has always relied on. Live-verified against a sandboxed build (playwright, 4 legs): TASK-9 and task-9 navigate to /tasks/TASK-9, bare 9 still navigates (regression leg), TASH-9 stays put (control leg — the instrument detects non-navigation, which is exactly what the pre-fix build does on a full ref). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * fix(web): ref miss probes without clobbering results; stale-query fence (codex r1) Three findings from review: (1) a ref-form miss overwrote the palette's results/total/facets with the bare-number probe's result set — query and display diverged, and loadMore() would page the typed ref against numeric results; the ref probe now reads into a local and leaves displayed state alone. (2) the async fallback had no guard against the user typing past the pending probe (pre-existing on the numeric path, newly exposed for refs) — fenced on the typed-at-Enter query. (3) the numeric miss fallback now searches exactly what was typed again (leading-zero queries had silently switched to the canonical number). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
4a2c4c1a39 |
fix(cli): suppress pad agent install's dangling (Y/n) prompt in non-interactive contexts (BUG-2593) (#1116)
installInteractive gated its prompt on cli.IsTerminal() (stdin only), so a pty-backed harness whose stdin looks like a char device — with nobody able to answer — got "Install /pad skill for all N? (Y/n): " printed and then hung at readChoice. Same shape and same fix as offerSkillInstall's BUG-2577 (PR #1111): swap to canPromptForConfig() (stdin AND stdout), document the both-pty undetectable boundary, keep the auto-install behavior unchanged. Test mirrors #1111's offerSkillInstall test and pins the closed-stdin no-prompt path; the discriminating pty-stdin case is live-verified on the trail (pre-fix binary prints the prompt and hangs to a 10s kill, fixed binary installs silently and exits 0 — identical undriven-pty harness). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
2580c2c8bb |
fix(cli): gate pad init's Step-4 login on canPromptForConfig() (BUG-2592) (#1115)
* fix(cli): gate pad init's Step-4 login on canPromptForConfig() (BUG-2592) A configured-but-unauthenticated non-interactive `pad init` fell into doBrowserLogin and blocked on the poll wait (wall-clock-bounded since BUG-2572, still minutes of hang nobody can complete) instead of failing fast — Step 3 has had this exact gate since init.go:205, and cmd_workspace.go got it in PR #1111 (BUG-2538). The gate sits AFTER the saved-credentials check so a headless run with valid stored credentials proceeds untouched. Remedy text per the corrected trail ruling (the r1 constraint was refuted by r2): piped `pad auth login --interactive` IS a working non-interactive login (doInteractiveLogin reads a plain bufio.Reader, piped-bytes-safe since BUG-1886), so the message points there — and deliberately not at pad init's --email/--name/--password, which only fire when SetupRequired. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * docs(plugin): pad init no longer hangs in the session-expired case — update the three claims + plugin 0.2.1 The BUG-2592 gate makes three plugin-skill passages stale (same shape as PR #1111's codex r3 self-invalidation): capture and onboard said `pad init` can still hang on the browser flow when configured-but- unauthenticated, and the pad skill's whoami-guidance said the same at its "not a safer probe" sentence. All three now state the fixed truth, live-verified this session: fixed binary fails fast in 0.1s with the piped-login remedy; pre-fix control binary hangs to the timeout kill in the identical sandbox state; the remedy itself (piped `pad auth login --interactive`) logs in and restores credentials. Plugin 0.2.1 — text reaches nobody without a bump (version-pinned at install). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
1882206bce |
docs(plugin): push-targeting etiquette + assignment-is-watch-only wording; plugin 0.2.0 (TASK-2591) (#1114)
* docs(plugin): push-targeting etiquette + assignment-is-watch-only wording; plugin 0.2.0 (TASK-2591) PLAN-2558 S6, the plugin-visible half that TASK-2551 deferred and S5 (PR #1108) made necessary: - monitors.json + SKILL.md no longer call assignment an addressed-to-you event (Phase 2 removed it from the addressed stream; assignment now arrives only via explicit watches) — the exact stale lines TASK-2564 recorded from PR #1092's codex round. - SKILL.md push etiquette covers S5 targeting: a push may be broadcast or targeted at one session (web composer picker / target_session_id; CLI always broadcasts), the notification line is identical either way, delivered_sessions is a pre-publish presence prediction (never a receipt, ~30s staleness on ungraceful drops), and pushes are never auto-retried — with the targeted-miss exception (delivered_sessions=0 on a targeted push means the publish was skipped, so a resend is safe by construction). - plugin.json 0.1.0 -> 0.2.0: the plugin is version-pinned at install (day-33, HANDO-120 delta (e)), so no text lands without the bump. - handlers_watch_events.go: the KNOWN-STALE pointer comment now records the fix instead of promising it. No behavior change. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * docs(plugin): delivered_sessions is API-response-only — CLI reports acceptance only (codex r1 P2) The sender-side bullet claimed the count was visible via pad push --format json; cli.PushResult omits DeliveredSessions, so CLI JSON cannot show it. State the truth instead: the API response carries it, the CLI surfaces nothing about delivery. Whether the CLI should surface it is a separate item, not a midnight scope expansion. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * docs(plugin): watches deliver item events, not pushes (codex r2 P3) "cover every event on the watched item" implied a watcher sees pushes on that item; a push is addressed dispatch (the KindPush branch returns before the watch map) and reaches only its addressee. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
ef903f0b22 |
feat(cli,mcp): --clear-parent / clear_parent to detach an item's parent (BUG-2078) (#1113)
* feat(cli,mcp): add --clear-parent / clear_parent to detach an item's parent (BUG-2078)
The server has honoured a present-but-empty "parent" key in fields_patch
as "clear the link" since BUG-2013, but neither the CLI (--parent ""
silently no-ops) nor MCP (parent is a plain string with the usual
"empty means not provided" convention) could reach it. Mirrors the
clear_assigned_user/clear_agent_role shape from IDEA-2584: a boolean
that carries its destructive meaning in its name and survives the trip
to local stdio MCP via BuildCLIArgs' snake_case-to-flag mapping.
Bumps ToolSurfaceVersion 0.18 -> 0.19 and updates the drift-pinned docs
(instructions.md, README.md) accordingly.
* test(cli,mcp): cover --clear-parent / clear_parent on both transports (BUG-2078)
CLI: --clear-parent sends fields_patch{"parent":""}; is absent when not
passed; conflicts with --parent and refuses without issuing a PATCH;
item create pins the deliberate create/update asymmetry.
MCP: clear_parent detaches through the real store+server (not a
recording handler) so the assertion is "item ends up unparented", not
just "payload shaped correctly"; clear_parent=false is inert; a plain
empty `parent` string stays a no-op (control leg); a simultaneous
parent + clear_parent is refused via both the direct param and the
--field-lifted route.
* fix(cli,mcp): close --clear-parent bypass via --field parent/plan aliases (BUG-2078, codex r1 P1)
extractParentLink (internal/server/handlers_items.go) resolves the parent
link from either a "parent" or a "plan" key in fields_patch, with no
early exit, so the later key in its own loop wins. The clear_parent
conflict check only covered one path each on the two client surfaces:
- CLI: the check ran BEFORE the --field overlay and only compared
against --parent's own value, so `--clear-parent --field parent=X`
(or `--field plan=X`) reached the wire unrejected — the --field loop
ran after clearParent's own `patch["parent"] = ""` and silently
overwrote it.
- MCP HTTP dispatcher: the check ran after the --field overlay (correct
ordering) but only inspected `patch["parent"]`, missing the "plan"
alias route.
Both surfaces now run the clear_parent check after every patch-building
step (named flags, --field overlay, column lift) and check both
"parent" and "plan" for a competing non-empty value.
* fix(cli,mcp): refuse --clear-parent/clear_parent when schema shadows "parent"/"plan" (BUG-2078, codex r2 #2)
extractParentLink (internal/server/handlers_items.go ~L606-610) is a
pre-existing, deliberate policy: it skips hierarchy handling entirely
when a collection's schema declares its own field literally named
"parent" or "plan", letting the value fall through as an ordinary
field write instead. Once {"parent":""} reaches the server it can no
longer distinguish clear-hierarchy intent from a legitimate
blank-my-schema-field write, so a client-side clear_parent request
against a shadowed collection used to report success while silently
blanking the data field AND leaving the real hierarchy link untouched
-- reproduced empirically before this guard existed.
The ambiguity is created at the surface that accepted the clear
request, so that surface refuses rather than pushing the decision
server-side (server-side refusal would also break legitimate blanking
of a real schema field).
CLI: the check is free -- collSchema is already fetched for --field
type parsing whenever any field change (including a bare
--clear-parent) happens.
MCP HTTP dispatcher: adds one conditional collection lookup, paid only
when clear_parent=true -- the common update path fetches no schema
today and doesn't start.
* docs: sync repo CLAUDE.md tool-surface contract to v0.19 (BUG-2078, codex r3 P2)
CLAUDE.md's MCP tool-surface prose still said "currently v0.18" and its
changelog omitted clear_parent -- a consumed-artifact gap, same rule as
the SKILL.md case: the doc a diff invalidates ships with the diff.
Synced three spots (intro paragraph, Tools bullet, ToolSurfaceVersion
stability-contract changelog) to v0.19, matching internal/mcp/version.go's
in-code entry's wording, plus the schema-shadow refusal (BUG-2078's
second follow-up commit) at the same level of detail the changelog
already gives the parent/plan alias conflict-refusal.
Grepped the rest of CLAUDE.md for any other 0.18/tool-surface reference
-- none found outside these three lines.
* docs: add schema-shadow refusal to version.go's v0.19 changelog entry (BUG-2078, codex r3 follow-up)
The in-code changelog is the canonical source; it was missing the
codex r2 schema-shadow refusal that a later commit added, which is
why CLAUDE.md and version.go briefly disagreed. Completes version.go
instead of letting CLAUDE.md drift ahead of it.
|
||
|
|
d895418ea2 |
fix(server): gate RequireAuth's cloud-secret bypass on validated session (BUG-1944) (#1112)
Sibling of TASK-1932's CSRFProtect fix: RequireAuth's isCloudAdminPath + hasCloudSecretMarker bypass fired on marker presence, not validated secret. Mirror TASK-1932's currentUser(r) == nil gate exactly. Concretely closes a disabled-admin gap: without the gate, a marker with the wrong secret let RequireAuth's own user.IsDisabled() check be skipped whenever a session was present, reaching handlers that trust a resolved admin session as an alternative to validateCloudSecret. In-handler validation for every cloudAdminPaths handler is unchanged and remains the independent layer for the genuine no-session sidecar case. |
||
|
|
ac05d8a2b1 |
fix(cli): fail fast and quiet on non-interactive workspace init (BUG-2538, BUG-2577) (#1111)
* Fail fast and quiet on non-interactive `pad workspace init` BUG-2538: initCmd drove runBrowserSetup/doBrowserLogin unconditionally when the instance needed first-run setup or login, blocking a non-interactive caller (script, CI, headless agent) on a browser handoff nobody can complete. Gate both branches on canPromptForConfig(), mirroring the precedent already used by `pad init` (init.go:205-206), and fail fast with a hint pointing at `pad init --email/--name/--password` or `pad auth setup`/`pad auth login`. BUG-2577: offerSkillInstall (shared by workspace init and workspace link) printed a "(Y/n): " prompt even when the answer would be auto-defaulted rather than read, because it gated on cli.IsTerminal() (stdin only). Switch to canPromptForConfig() (stdin AND stdout), which is the same predicate now used for BUG-2538 and the more robust of the two checks already in the codebase. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * Fix wrong remedy in BUG-2538's !Authenticated error message codex r1: the !Authenticated branch suggested `pad init --email/--name/--password`, but those headless flags only bootstrap the first admin account and only fire when SetupRequired — for an already-set-up-but-unauthenticated instance, `pad init` falls through to its own ungated Step 4 re-auth (BUG-2592), so the suggestion relocated the hang instead of avoiding it. Drop the pad-init suggestion in this branch only; point at `pad auth login` and note there's no non-interactive login path yet. SetupRequired branch is unchanged — its pad-init suggestion is correct for that state. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * Fix two more inaccurate remedies flagged by codex r2 1. SetupRequired branch: `pad init --email/--name/--password` silently eats the caller's workspace name/--template — pad init creates its own CWD-named workspace as a side effect, so a re-run of the original `pad workspace init <name> --template <t>` short-circuits on the link pad init just made with no signal <name>/<t> were ignored. Switch the remedy to `pad auth setup --email/--name/--password`, which bootstraps the admin account only (no workspace side effects), then re-run the original command. 2. !Authenticated branch: the "no non-interactive login path exists" claim was false — `pad auth login --interactive` reads email/password off a plain, TTY-ungated bufio.Reader (doInteractiveLogin, cmd_auth.go:554+; BUG-1886 made it piped-bytes-safe), so it works fine when credentials are piped in. Reworded to point at it and dropped the incorrect BUG-2592 reference (that bug tracks pad init's ungated Step 4, not a missing login mechanism). TestWorkspaceInitNonTTYSetupRequired's assertion updated from "pad init" to "pad auth setup" to match; TestWorkspaceInitNonTTYNotAuthenticated needed no change (still asserts "pad auth login"). Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * Update skill docs invalidated by the non-interactive fast-fail fix codex r3: BUG-2538/BUG-2577 made this diff's own docs stale. Four files (skills/pad/SKILL.md, plugin/skills/pad/SKILL.md, plugin/skills/onboard/SKILL.md, plugin/skills/capture/SKILL.md) still say non-interactive `pad workspace init` on a configured-but- unauthenticated machine "blocks for minutes with no non-interactive fallback" — that was true pre-fix (per BUG-2541's verification) and is false now. Reworded the WHY without dropping the underlying do-not-run-blind guidance: an agent's tool call is always non-interactive, so it now gets a fast, actionable error instead of a hang, but the error still just says a human needs an interactive terminal — `pad auth whoami` remains the right check to run instead. Where the docs' `pad init` claims are about the still-unfixed session-expired path (BUG-2592, this diff's Step-4 sibling, left untouched), those claims are unchanged and now cite BUG-2592 explicitly. skills/INSTALL.md:24 updated separately (P3): notes the non-interactive silent-install branch of `pad workspace init`'s skill offer, alongside the existing interactive-prompt description. Docs only, no Go changes — go build/test and embed.go's //go:embed skills/pad/SKILL.md still resolve; no test asserts the old wording. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
30274057eb |
fix(release): keep RC tags off brew and docker :latest (BUG-2524) (#1110)
A prerelease tag (vX.Y.Z-rc.N) published the RC to both mainstream install channels: homebrew_casks had no skip_upload (goreleaser's default uploads the cask for prereleases too) and dockers_v2 listed "latest" unconditionally, so every tag moved the floating tag. Both were verified serving 0.12.0-rc.1 during the v0.12.0 cut. skip_upload: auto skips cask upload for prereleases; the conditional latest template evaluates empty on RCs, and goreleaser ignores empty tags (documented behavior, and the docs' own conditional-tag example uses this exact shape). Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
7d5d3bd672 |
fix(cli): give the CLI auth poll loop its own wall-clock timeout (BUG-2572) (#1109)
* fix(cli): bound pollAndSaveCLIAuth with its own wall-clock timeout (BUG-2572) pollAndSaveCLIAuth had no wall-clock limit of its own — the ~5m bound users rely on was purely the server-side session TTL, so an unreachable server after session creation left the poll loop spinning forever on Ctrl-C alone. Add a 20m timer (matching the longer of the two server TTLs, since this helper is shared by both the plain login and first-run setup flows) plus a consecutive-transient-error bound so a permanently unreachable server fails fast with a network-shaped error instead of waiting out the full timeout. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * fix(cli): make poll-error headline accurate for HTTP-error servers (BUG-2572 r2) The consecutive-error bail-out message claimed "could not reach server", but client.get returns an error for both transport failures and non-2xx HTTP responses, so a server that's reachable but persistently returning 500 got misreported as unreachable. Bailing out fast is still correct for that case; only the headline was wrong. Switch to a cause-neutral message and let the wrapped error carry the specifics (codex round 2). Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
00a91dfcf4 |
feat(push): session targeting — target_session_id + delivered_sessions (TASK-2588) (#1108)
* watchevents: add session-targeted push delivery predicate
PLAN-2558 S5 (TASK-2588). Notification gains TargetSessionID,
evaluated in the existing per-connection KindPush predicate in
watchNotificationVisible alongside TargetUserID — one delivery path,
targeted is broadcast-with-a-predicate, no bus changes. Empty
TargetSessionID (the pre-S5 shape) still matches every one of the
target user's sessions.
* server: accept target_session_id on push, report delivered_sessions
PLAN-2558 S5 (TASK-2588). POST .../items/{slug}/push accepts an
optional target_session_id (an id from GET /api/v1/sessions) and the
response gains delivered_sessions — a prediction read from the S1
presence registry at push time, scoped to the caller's own
ListForUser(userID) so a vanished id and one belonging to a different
user are both an honest 200/0 with no existence oracle across users.
Omitting the field keeps the exact pre-S5 request/response shape.
* web: session picker in the push composer, targeted-miss handling
PLAN-2558 S5 (TASK-2588). PushToAgentDialog gains a target picker
(broadcast default + one option per live session), reusing the
presence read already fetched for the count — no second GET
/api/v1/sessions. Selecting a session passes target_session_id;
leaving it untouched keeps the exact pre-S5 3-argument push() call.
A targeted miss (delivered_sessions === 0) toasts "that session is
gone — refresh the list", drops the selection back to broadcast, and
re-polls presence instead of closing — zero delivery means nothing
was sent, so nothing is duplicated by resending.
* server: bound target_session_id, skip publish on a targeted miss
Codex round 1 fixes for TASK-2588:
- Cap target_session_id at 256 runes (400 over-cap) so an authenticated
caller can't park arbitrary garbage in the bus's shared replay buffer;
a registry-issued id (36 runes) can never hit this bound.
- Snapshot presence BEFORE publish instead of counting after: the old
order raced a target disconnecting between publish and count, which
could report delivered_sessions=0 on a push that had already landed
once. A targeted push now skips the publish entirely when its id
isn't in the pre-publish snapshot — session ids are per-connection
and never reused, so a target absent now can never be matched later,
making the 0 a guarantee rather than a race. Broadcast is unaffected
(still publish-always, pre-publish count).
Strengthened the targeted-miss and cross-user tests to assert the bus
does not grow (not just that the notification fails to arrive
downstream) — verified this fails if the skip-on-miss guard is
reverted.
* push targeting: document the pushed ruling, fix stale picker selection, guard mixed-version responses
Codex round 2 dispositions for TASK-2588:
- pushed:true on a skipped publish is RULED, not a bug (dispatcher):
moved the ruling from a test comment onto the contract itself —
pushResponse.Pushed's own doc comment in Go, mirrored in the TS
ItemPushResult doc comment.
- Fixed a real sharp edge: when a presence refresh drops the selected
session, a <select> can visually fall back to "All connected
sessions" while the bound value stays the stale id, so the wire
would carry a dead target the UI no longer shows as selected.
Added reconcileSelectedSession(), called at every point `sessions`
is reassigned outside the fresh-open reset (a live poll, a failed
read, and the staleness-expiry path).
- Guarded the mixed-version hazard with a cheap check, not capability
negotiation (the deployment shape — web assets embedded in the
server binary — bounds this to a transient stale tab, argument
recorded in the comment): delivered_sessions is now optional on the
wire type, and a targeted send whose response omits it entirely is
treated as UNKNOWN (info toast, dismiss like a normal success) —
never inferred as a confirmed miss.
Verified all three new/changed legs actually catch their regression
by temporarily reverting each fix and confirming the corresponding
test fails, then restoring.
* push targeting: fix stale publish-guarantee comments (codex round 3)
Two doc-comment remnants of round 2's skip-on-miss fix, both claiming
push unconditionally publishes:
- watchevents.KindPush's doc comment ("publishes exactly one of
these") now notes handlePushToItem decides whether to publish at
all, and points at TargetSessionID / pushResponse.DeliveredSessions
for why.
- api.items.push()'s JSDoc in client.ts no longer claims a resolved
promise means "published to the bus" unconditionally — a targeted
miss resolves with delivered_sessions: 0 and nothing published.
Comment-only; no behavior change.
|
||
|
|
d7da237198 |
feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584) (#1107)
* feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584)
v0.16 and v0.17 made unassigning WORK. Nothing advertised it. The params
that do it — `assigned_user_id` / `agent_role_id` — were never in the
catalog, so an agent reading the tool schema to find out how saw only
`assign` (a name) and reached for `assign: ""`, which is a no-op and
deliberately stays one. The capability existed with no name an agent
could find.
`clear_assigned_user` / `clear_agent_role` booleans on `pad_item`, backed
by new `--clear-assigned-user` / `--clear-agent-role` bareword flags on
`pad item update`.
WHY BOOLEANS rather than declaring the existing string params. Two
reasons, and the second decided it:
1. An empty DECLARED string is inert everywhere else on this tool
(title, content, comment, tags), so a client that pads optional
params with "" instead of omitting them is harmless today. Giving
one a destructive meaning would turn that same client into one that
silently unassigns every item it touches. A boolean carries its
meaning in its name and can't be tripped that way.
2. Only a boolean can REACH local stdio. BuildCLIArgs emits the CLI's
real flags, so a catalog param with no flag behind it is dropped
before dispatch — declaring `assigned_user_id` would have left the
direct form remote-only, i.e. would not have closed the gap this
change exists to close. That fact reframed the design fork and is
what the ruling turned on.
Server-side this is WIRING, not new semantics:
models.ItemUpdate.ClearAssignedUser / ClearAgentRole already existed and
the store has honoured them since BUG-2566, on the same branch as the
empty-string form. The older forms keep working and are NOT deprecated;
they're just not what the schema advertises.
UPDATE ONLY, deliberately asymmetric with create, and recorded in-place
at both the flag registration and the catalog description so a
symmetry-minded reader meets the reasoning before the "fix": clearing at
create is a request to not-set something never set, whose only honest
behaviour is a no-op — it teaches a wrong affordance and pads every
create call's schema. A test fails if someone adds them there.
CLI precedence is the OPPOSITE of the --field lift's, deliberately: an
explicit `--clear-assigned-user` beats `--assign`, because that
combination is a contradiction the user typed and the reading that
cannot silently assign somebody is the safer one. Tested.
The dispatcher forwards the booleans VERBATIM rather than only-when-true.
A `&& b` guard would read as the thing protecting a param-padding client
and would be lying: what makes `false` inert is the store. Same call I
made on #1106's `len(patch) > 0` — a guard that reads as load-bearing
while doing nothing is worse than none.
ToolSurfaceVersion 0.17 -> 0.18, ADDITIVE bump per the v0.5 / v0.6
precedent: no existing tool, action or param changed shape.
Consumed artifacts moved in the same commit, which is the whole point of
this change — the schema IS the deliverable: catalog_item.go (the schema
agents read, plus an `assign` description that now says where to find the
clear), instructions.md (leads with the boolean, mentions the older forms
as still-working), version.go, README, CLAUDE.md.
VERIFIED LIVE, five legs, both transports:
CLI --clear-assigned-user -> assigned=None, role intact
CLI --clear-agent-role -> role=None
stdio clear_assigned_user:false -> assignment SURVIVES and the
update still applied (title
changed) — the control that
makes the boolean safe to
declare at all
stdio clear_assigned_user:true -> assigned=None
stdio clear_agent_role:true -> role=None
Three mutations, each failing only its own tests: dropping the dispatcher
forwarding; hardcoding true in the dispatcher (fails the false-control);
dropping the CLI flag wiring.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Closes IDEA-2584.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(mcp,cli): refuse a simultaneous set-and-clear (codex round 1)
Codex found a real bug, and the more useful half of the finding is that
MY OWN TEST FOR IT WAS VACUOUS.
The store's branch order is `if AssignedUserID != "" { set } else if
ClearAssignedUser { clear }`. So `--assign wren --clear-assigned-user`
assigned Wren and the clear evaporated. My in-place comment claimed the
opposite ("an explicit clear wins"), and the test I wrote to prove it
asserted `body["clear_assigned_user"] == true` — that the FLAG was set,
not that the item ended up unassigned. The flag was set. The behaviour
was backwards. A test that asserts a field is present says nothing about
which field wins.
Both surfaces now REFUSE the contradiction rather than silently resolving
it. Rejecting beats picking a winner here: the store already picks one
silently, which is the bug; and a caller who typed both wants to be told,
not guessed at. Precedent in the same command family — `item list`
already makes `--parent` and `--unparented` mutually exclusive.
PLACEMENT IS THE LOAD-BEARING PART, and I got it wrong first. There are
two routes to a competing value: `--assign` / `assigned_user_id`, which
resolve early, and `field: ["assigned_user_id=<uuid>"]`, which reaches
the payload via liftFieldsToColumns LATER. My first version checked
between them and its comment asserted the lift "has already" run — it
hadn't. That version rejects the direct case and lets the lifted case
through: a half-fix that reads as complete. The check now runs after
both, in the CLI after --assign/--role resolution and the lift, in the
dispatcher immediately before the body marshal.
That mutation is now a test: moving the dispatcher check back to the
pre-lift view fails ONLY the two `lifted …` subtests and passes the
direct one — the exact shape of the bug I nearly shipped.
Tests assert the OUTCOME, not the message: a refused conflict must leave
the item's assignment AND role untouched, and the CLI must issue no PATCH
at all. An error string alone wouldn't prove the write didn't happen.
Agent-facing text moved with it (the consumed-artifact step): both
catalog descriptions, instructions.md, and the v0.18 version entry now
say the combination is refused. An agent that pairs them gets a
structured refusal, so the schema has to say so.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
312f28dd14 |
chore(deps)(deps-dev): bump vitest from 3.2.6 to 4.1.10 in /web (#1045)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.6 to 4.1.10. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
847ee73327 |
fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583) (#1106)
* fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583)
`pad item update TASK-9 --field assigned_user_id=<uuid>` wrote the pair
into the item's FIELDS JSON BLOB while the column stayed stale, and then
printed "Updated TASK-9". Two defects in one line: a success message for
a write that did nothing the caller asked for, and a blob key shadowing a
real column's name, so the CLI surface diverged from store/HTTP/MCP
truth. The empty-string case was the same defect wearing a worse hat —
it was the only route an agent had to unassign an item.
Blast radius beyond the CLI: local stdio MCP (`pad mcp serve` — Claude
Desktop, Cursor, Windsurf) dispatches through ExecDispatcher, which
shells out to this CLI. So TASK-2571's fix reached the remote /mcp
transport only, and the transport most agents actually use still could
not unassign. This closes that half.
`cmd/pad/cmd_item.go` now lifts `columnFieldKeys` out of the --field map
onto the column pointers, on CREATE and UPDATE both, mirroring
internal/mcp/dispatch_http.go's liftFieldsToColumns — including its
INVARIANT, which is the part that matters: only keys with defined
clear-to-NULL semantics for "" belong in the list, and `tags` never does
(an empty write corrupts a JSONB column rather than clearing it). A test
fails if anyone adds it.
Two compat changes, ruled separately by the lead:
Q1 non-empty values move to the COLUMN and stop writing the blob key.
Accepted: relying on the old behaviour is relying on a shadowing
defect.
Q2 empty values clear the column. Falls out of the lift, inheriting
BUG-2566's store semantics.
`agent_role_id` gets identical treatment. Existing stray blob keys are
left alone per the ruling — this stops minting new ones; a sweep would
be its own change.
Precedence is explicit and tested: `--assign` / `--role` win over a
lifted --field value, matching liftFieldsToColumns' "caller-supplied
top-level values win". It is delivered by the ORDER of two blocks in the
command, which is exactly the kind of thing that gets reordered by
accident, so there is a test whose only job is to fail when it does.
A non-string --field value is deliberately NOT lifted: a collection that
genuinely declares a field with one of these names makes parseFieldFlag
return a typed value, which cannot address a column. It stays in the
blob — today's behaviour and the only lossless option.
ToolSurfaceVersion 0.16 -> 0.17, and v0.16's transport-scope paragraph
now points forward rather than claiming a limitation that no longer
holds. Behaviour-only bump again, same grounds as v0.16 and v0.9. The
CLI's own marker, CmdhelpVersion, deliberately does NOT move: its
contract is flag/arg SCHEMAS, and no flag or argument changed shape.
instructions.md — the text agents receive at handshake — drops the
"remote only" caveat it carried since TASK-2571. That file is the reason
this PR exists in the shape it does: it is the artifact the actor reads,
and it was the one place the previous PR overclaimed.
VERIFIED LIVE against a running server, with a negative control, because
the claim is about a transport rather than a function:
legs, fixed binary
--field assigned_user_id= -> column CLEARED, blob clean
--field assigned_user_id=<uuid> -> column SET, blob clean
--field agent_role_id= / <uuid> -> same, sibling column untouched
stdio MCP tools/call pad_item
action=update field=["assigned_user_id="]
-> column CLEARED, blob clean
control, PRE-FIX binary, same server + same item + same JSON-RPC bytes
-> column UNCHANGED, blob polluted
with {"assigned_user_id":""}
Six unit tests in cmd/pad/item_column_fields_test.go, four mutations each
failing only its own test (no lift; drop non-strings; flip the
lift/assign precedence; add `tags` to the list). One assertion was
rewritten after mutation testing showed it was VACUOUS: `len(fields_patch)
!= 0` passes whether the key is absent or present-and-empty, so it now
asserts key PRESENCE — confirmed by mutating `omitempty` off the model
field and watching the old form stay green. The redundant `len(patch) > 0`
guard that assertion was meant to cover is gone too; `omitempty` already
does that job, and a guard that reads as load-bearing while doing nothing
is worse than no guard.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* test(cli): cover the create half of the column lift (BUG-2583)
Codex came back CLEAN, but the review reminded me I'd changed `item
create` and only tested it through `liftColumnFields` directly — no test
asserted what create actually puts on the wire. That's the weaker half to
leave uncovered, not the stronger one: on update a wrong write contradicts
a visible prior value, while on create the column-named key is simply
baked into the blob at birth with nothing to contradict it.
The assertion has to parse rather than index, because ItemCreate.Fields is
a JSON-encoded STRING and not a nested object — a body["fields"]["…"]
lookup would have been vacuous in a way that looks fine.
Mutation-tested like the rest: neutralizing the create-side lift fails
this test and only this test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): say WHICH form of the unassign works on which transport (codex round 2)
Codex round 2, and it is the same class of defect as the previous PR's
round 2 — an overclaim in the artifact agents actually read. My
instructions.md said "works on BOTH transports" of two forms that do not
behave the same:
field: ["assigned_user_id="] clears on BOTH transports
assigned_user_id: "" clears on REMOTE ONLY
The direct params are not declared in pad_item's schema. They reach the
remote mapper only by riding the verbatim input map; on stdio,
BuildCLIArgs drops unknown keys, so the call does nothing.
VERIFIED, not accepted on the reviewer's word, and the verification
corrected my own first reading. My initial probe appeared to show the
stdio call CORRUPTING the fields blob — but that blob key was leftover
state from the earlier pre-fix control leg, not something the probe
wrote. Re-run against a freshly created item, the two forms separate
cleanly:
before assigned=b6786b13... fields={priority,status}
after assigned_user_id:"" assigned=b6786b13... fields={priority,status} (clean no-op)
after field:["assigned_user_id="] assigned=None fields={priority,status} (cleared)
So the stdio behaviour of the direct param is a DROP, not a corruption —
worth stating precisely, because "it corrupts the blob" would have sent
the next reader hunting a bug that isn't there. (Identity-doc rule: a
guessed mechanism stated as the reason is a claim, not a hedge.)
instructions.md now leads with the form that works everywhere and names
the remote-only limitation of the other; version.go and CLAUDE.md say the
same. IDEA-2584 — declare the params properly — is the fix that would
collapse this distinction, and is now cited from all three.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(cli): don't lift a field the collection actually DECLARES (codex round 3)
Nothing reserves `assigned_user_id` or `agent_role_id` as field names, so a
collection may legally declare a field with one of those keys. For that
collection `--field assigned_user_id=foo` means the DECLARED field — and
the lift I just added would redirect it to the assignment column while
dropping the value the user set. Two wrongs from one line: the intended
write vanishes and an unintended one happens.
liftColumnFields is now schema-aware and never lifts a declared key. Cheap
to do here because both call sites already fetch the collection schema for
parseFieldFlag. The check is PER-KEY — an undeclared sibling still lifts,
so one collision doesn't disable the feature — and a schema-fetch failure
degrades toward lifting, matching how the rest of --field handling degrades.
This makes the CLI deliberately STRICTER than the MCP dispatcher it
otherwise mirrors. liftFieldsToColumns has the identical collision and
can't make the same check as written: it builds its fields map straight
from the tool input without fetching a schema. Filed as IDEA-2587 rather
than fixed here, because closing it costs a round-trip on a hot path while
the CLI fix was free — and recorded so the divergence is KNOWN, in the safe
direction, rather than something a later reader "fixes" by loosening the
CLI to match.
The old non-string branch stays as belt-and-braces: parseFieldFlag only
returns a non-string for a declared field, which the new check already
catches, but if that stops being true a non-string still can't address a
column.
Mutation-tested: ignoring the schema declaration fails the new test and
only that test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
b887b0bfe1 |
test(web): capture pristine DOM probes at module load in mockOpenModals helpers (#1105)
vitest 4 hands back the SAME spy when vi.spyOn targets an already-spied method, so a helper that re-captures "the real function" mid-test captures the spy itself and the pass-through branch recurses (swallowed by the :modal probe's guard, which then reads as ':modal unsupported'). Capturing document.querySelectorAll / Element.prototype.matches once at module load is correct under both vitest 3 and 4; suite measured 1609/1609 on each. Unblocks the vitest 3->4 major (dependabot #1045), whose merged-tree run failed 3 Lightbox drag-abort tests (TASK-2458) through this pattern. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
a7b70c2092 |
chore(deps)(deps-dev): bump @testing-library/jest-dom in /web (#1044)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.1. - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.1) --- updated-dependencies: - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
a8989a0ad3 |
chore(ci)(deps): bump actions/attest-build-provenance (#1071)
Bumps the actions-minor-and-patch group with 1 update in the / directory: [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance). Updates `actions/attest-build-provenance` from 4.1.1 to 4.2.2 - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/0f67c3f4856b2e3261c31976d6725780e5e4c373...4d101475d8b20a2381f78447822ac1eab6504dd8) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
f1bd144b02 |
chore(ci)(deps): bump actions/checkout from 6.0.2 to 7.0.1 (#1073)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6.0.2...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
8ba08c7164 |
chore(deps)(deps): bump the npm-minor-and-patch group (#1072)
Bumps the npm-minor-and-patch group in /web with 7 updates: | Package | From | To | | --- | --- | --- | | [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.0.0` | `3.1.0` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.0` | `11.16.1` | | [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.77` | `0.9.78` | | [yjs](https://github.com/yjs/yjs) | `13.6.31` | `13.6.32` | | [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` | | [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.4` | `4.7.5` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.1` | Updates `@dagrejs/dagre` from 3.0.0 to 3.1.0 - [Release notes](https://github.com/dagrejs/dagre/releases) - [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md) - [Commits](https://github.com/dagrejs/dagre/compare/v3.0.0...v3.1.0) Updates `mermaid` from 11.16.0 to 11.16.1 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1) Updates `svelte-dnd-action` from 0.9.77 to 0.9.78 - [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md) - [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits) Updates `yjs` from 13.6.31 to 13.6.32 - [Release notes](https://github.com/yjs/yjs/releases) - [Commits](https://github.com/yjs/yjs/compare/v13.6.31...v13.6.32) Updates `marked` from 18.0.7 to 18.0.9 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9) Updates `svelte-check` from 4.7.4 to 4.7.5 - [Release notes](https://github.com/sveltejs/language-tools/releases) - [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.7.4...svelte-check@4.7.5) Updates `vite` from 8.2.0 to 8.2.1 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite) --- updated-dependencies: - dependency-name: "@dagrejs/dagre" dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-and-patch - dependency-name: mermaid dependency-version: 11.16.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte-dnd-action dependency-version: 0.9.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: yjs dependency-version: 13.6.32 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: marked dependency-version: 18.0.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte-check dependency-version: 4.7.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: vite dependency-version: 8.2.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
ee05c58446 |
fix(mcp): let an agent clear an item assignment (TASK-2571) (#1104)
* fix(mcp): let an agent clear an item assignment (TASK-2571)
Two filters in the MCP dispatch path dropped an empty-string assignment
value before the request body was built, so an MCP agent had no way to
UNASSIGN an item — `assigned_user_id=""` was a silent no-op rather than a
clear or an error:
- mapItemUpdate's top-level pass-through (dispatch_http_advanced.go)
- liftFieldsToColumns (dispatch_http.go), which lifts `--field` entries
onto their columns. This is the path an agent actually reaches: the
catalog exposes `assign` (a name) and `field`, but no
`assigned_user_id` param, so `field: ["assigned_user_id="]` is the
only schema-visible way to ask.
Both were right when written — `""` had no defined meaning at the store
and bound an empty string into a FK column. BUG-2566 gave `""`
clear-to-NULL semantics for exactly these two columns and the HTTP
surface inherited it, which left MCP the odd surface out. Uniformity
restoration, not a new feature.
Compat posture ACCEPTED per the lead's ruling: a caller sending `""`
today gets a no-op, and after this gets a clear. That is the correct
reading of the input — nobody sends an empty assignment ID meaning
"leave it alone" — and the no-op is the surprising half. Option (b)'s
clear_assigned_user / clear_agent_role schema flags are deliberately
skipped as additive sugar.
The empty-string filter on `tags` three lines above STAYS (codex #547 r3
P2): `tags: ""` is not a clear, it is a corrupt write into a JSONB
column on Postgres and TEXT on SQLite. Same-looking guard, opposite
justification — the new test's control leg fails if someone "unifies"
them.
ToolSurfaceVersion 0.15 -> 0.16. No tool, action, or parameter shape
changed, so this is a BEHAVIOUR bump on the v0.9 precedent (which moved
for a return shape with an unchanged signature). Flagging it for the
lead as my call, not theirs — it is a one-line revert if they read the
contract differently.
TRANSPORT SCOPE, established live rather than assumed: this fixes the
REMOTE /mcp transport, where both filters lived. LOCAL STDIO MCP still
cannot clear, because ExecDispatcher shells out to the CLI and the CLI
has no unassign at all — `--assign`/`--role` skip on empty, and
`pad item update TASK-9 --field assigned_user_id=` writes
{"assigned_user_id":""} into the item's FIELDS BLOB while the column
stays set (observed against a running server). Separate defect, CLI-wide
blast radius, filed separately rather than riding along on a ruled-scope
PR. The version-history entry says so explicitly so the note can't be
read as covering it.
Tests: internal/mcp/dispatch_http_clear_assignment_test.go drives the
REAL server + store, not a recording handler — asserting the dispatcher
merely puts `""` in the payload would restate the fix rather than test
it. Three mutations, each failing only its own test: restoring the
top-level filter fails the two direct-param tests; restoring the lift
filter fails the --field test; removing the tags filter fails the
control leg.
go test ./internal/mcp ./internal/store ./internal/server — all pass.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): record why an empty `assign` alias still doesn't clear (codex round 1)
Codex's finding is REAL: the catalog exposes `assign` / `role`, not
`assigned_user_id` / `agent_role_id`, so an agent reading the schema will
reach for `assign: ""` to unassign and get a no-op. The fix as shipped
only covers the params an agent has to already know exist.
Its suggested remedy — map the empty aliases to a clear — is the riskier
of the two it lists, and I've deliberately not taken it.
`assign` is SCHEMA-DECLARED. Every other schema-declared string on this
mapper (title, content, comment, tags) follows one convention: empty
means NOT PROVIDED. An MCP client that fills declared optional params
with "" instead of omitting them is harmless today; making `assign: ""`
mean "clear" would turn that same client into one that silently
unassigns every item it touches — destructive, silent, and inconsistent
with the four params beside it. That is exactly why the same change IS
safe for `assigned_user_id`: an agent can only send it deliberately.
The remedy that closes the gap without that hazard is the other one
codex names — explicit clear_assigned_user / clear_agent_role params,
i.e. option (b) on TASK-2571, which the lead deferred as additive sugar.
This finding is new evidence for revisiting that, so it goes to the lead
as a decision rather than being taken unilaterally in a ruled-scope PR.
Adds the reasoning at both call sites and a test that pins the limit, so
a future "finish the job" edit fails a test and has to be a decision
rather than a drive-by. The MCP instructions already name the working
form meanwhile.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): scope the unassign instructions to the transport where it works (codex round 2)
Codex round 2, and it caught a defect in my own round-1 documentation
fix. instructions.md is the text sent to agents at handshake, and BOTH
transports serve the same string — so telling agents "pass
assigned_user_id: '' to unassign" was true on remote /mcp and a lie on
local stdio, where ExecDispatcher shells out to a CLI that has no
unassign path. I had scoped the claim carefully in version.go and the
commit message and then overclaimed in the one place agents actually
read.
The instructions now name the transport, say plainly that stdio ignores
the value, and tell the agent to verify rather than assume. An agent can
act on a conditional; it cannot act on a claim that is false half the
time.
Both gaps are now filed rather than merely described:
BUG-2583 — the CLI has no unassign at all, and `--field
assigned_user_id=` writes into the item's FIELDS BLOB
while the column stays set (verified live: fields became
{"assigned_user_id":"", ...} and the CLI printed
"Updated TASK-9"). This is what makes stdio MCP fail.
IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
so an agent reading the schema still cannot discover the
clear. Reopens option (b) with codex's evidence.
version.go and CLAUDE.md now cite both refs, so the version-history
entry can't be read as covering more than it does.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): cite BUG-2583 / IDEA-2584 in the version history and CLAUDE.md
Follow-up to the previous commit: its scripted edits to version.go and
CLAUDE.md silently no-op'd (a gofmt rewrap moved the anchor text), so
only instructions.md actually changed. Caught by grepping for the refs
rather than trusting the commit.
Both files now name the two filed gaps, so the v0.16 entry cannot be
read as covering more than it does:
BUG-2583 — the CLI has no unassign, which is why local stdio MCP
still can't clear.
IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
so the clear stays undiscoverable from the schema.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
bfa90826e9 |
feat(web): quick actions push to a connected agent session (TASK-2562) (#1103)
* feat(web): quick actions push to a connected agent session (TASK-2562)
PLAN-2558 S4. `resolvePrompt()` already did the templating push needed
(`{ref} {title} {status} {priority} {collection} {content} {fields} {plan}
{phase}`); only the last hop was a clipboard ferry. That hop is now a push,
and the clipboard becomes the fallback rather than the mechanism.
Zero-touch migration, as the task required: quick actions are still
`{label, prompt, scope, icon}` in collection settings. No settings rewrite,
no schema change, no per-action opt-in.
Routing, per the plan's ruling — with zero live sessions, fall back to the
clipboard with an honest toast, never a hard error and never a queue:
session(s) live push the collapsed prompt; toast hedges ("delivery isn't
confirmed") because a push gets no ack
zero sessions copy, "No agent session connected — copied to clipboard
instead"
can't tell copy. This is where S4 DIVERGES from S3's dialog, which
leaves Send enabled on an unreadable presence answer. The
dialog is right to: the warning is on screen and the user
chooses with it in front of them. A quick action asks
nobody, so the tie goes to the lossless branch — copying
when we could have pushed costs one paste, pushing into
nothing loses the instruction outright.
no item to collection-scope actions keep the pre-S4 behavior exactly.
address The endpoint is POST .../items/{slug}/push and there is
nothing to point it at, so they don't even spend a
presence read finding that out.
PRESENCE IS READ WHEN THE MENU OPENS, NOT ON THE CLICK, and that is the one
non-obvious thing in this diff. Both clipboard APIs want the user gesture
that is live during the click handler and gone after a network round-trip
(Safari strictest, Firefox too). Deciding from a read issued on the click
would put an await in front of the very fallback this slice promises. The
cost is a small window — click before the first read lands and presence is
null, which routes to the clipboard with an honest toast — and that is the
right way round.
The menu's footer line now says which way the next click will go, so the
routing is visible before it happens rather than only in the toast after.
A logged-out or workspace-token viewer gets a 401 from /sessions, which is
"can't tell", which is the clipboard — today's behavior, no gating needed.
Push failure splits on the same line CopyItemDialog and the S3 composer draw
(DR-13): a recognised pre-publish refusal means nothing went out, so the copy
is OFFERED as a toast action (a fresh gesture, which is what makes a
clipboard write work this long after the original). An unrecognised failure
leaves the outcome unknown — the handler publishes BEFORE it writes its
response — so nothing is offered, because a paste would be the duplicate the
message is warning about on an endpoint with no idempotency key.
PRE_PUBLISH_ERROR_CODES moved out of PushToAgentDialog into
$lib/push/dispatch so the two surfaces can't drift on it.
Also: the local `copyToClipboard` is replaced by `$lib/utils/clipboard`'s.
The local one returned true from the promise path WITHOUT awaiting it, so a
rejected write reported success and never reached the execCommand fallback —
harmless when copying was a convenience, not harmless now that "we copied
instead" is a load-bearing claim.
Verified live against a throwaway instance (built binary, real browser),
three legs, with the SSE stream as the receipt:
no session tagline "No agent session connected — actions copy to your
clipboard"; toast matches the ruling; clipboard holds
"Implement TASK-9: Ship the thing (status open)"
one session tagline "Pushes to your connected agent session"; the
connected stream RECEIVED {"kind":"push","item_ref":
"TASK-9","summary":"Implement TASK-9: ..."}; clipboard
untouched
presence 503 same live session still connected, /sessions aborted: copies
instead, and the stream's push count did NOT increase — the
counterfactual, not just the end state
Each of the five behaviours is mutation-tested 1:1 against its test: an
await before the copy fails ONLY the synchronous-gesture test; routing
'unknown' to push fails only the two uncertainty tests; dropping the collapse
fails only the raw-vs-collapsed test; offering a copy on an unconfirmed push
fails only that test; copying on the happy path fails three.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): expire a stale presence answer in the quick-actions menu (codex round 1)
Codex's one finding, and it is real. A FAILED poll already degrades to
'unknown'; a poll that HANGS does not — it simply never writes, so the last
count stayed in place indefinitely while the menu went on offering a push
into a session that may have dropped minutes earlier. That is the one
direction that loses the user's instruction, which is the whole thing this
slice exists to prevent.
A 'known' answer now expires after 30s without a refresh — the server's own
worst-case presence staleness (watchEventsKeepaliveInterval), the same bound
and the same reasoning as PushToAgentDialog's round-2 fix. Requests already
in flight at the moment of expiry are retired (presenceAppliedSeq advances to
presenceSeq) so one issued BEFORE the expiry cannot land after it and restore
the very count we just declared too old to trust.
The expiry is checked in TWO places, and the second is the one worth noting:
the poll tick rewrites the state (so the footer line stops claiming a
connection), but the ROUTING decision reads through `currentPresence()` at
click time. A tick-only expiry leaves a window of up to one whole poll
interval in which the menu still pushes against a count it has already
outlived — and a click is exactly what lands in that window.
Both halves mutation-tested: disabling the expiry fails the two staleness
tests and leaves the control leg (polls still landing → no downgrade) green;
reading raw `presence` at click time instead of `currentPresence()` fails
ONLY the between-ticks test.
npm run check 0 errors · web unit suite 1607 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): the offered copy reports its own outcome (codex round 2)
Round 2's one finding, and it is real. `Copy instead` on a push-failure toast
discarded the `copyToClipboard()` result, and taking the offer dismisses the
toast that carried it — so a failed copy said nothing at all. That silence is
worst on exactly this path: a pre-publish refusal means the instruction was
never sent, so a silently-failed copy leaves it neither sent NOR copied, with
the user believing they rescued it.
The offer now routes through the same `copyAndAnnounce` every other clipboard
path uses, under a new `'offered'` ClipboardReason that renders the plain
"Copied to clipboard" — the user asked for the copy, so there is no absent
push to explain — and the ordinary error on failure.
Mutation-tested: reverting to the discarded-result form fails the new test
and the existing pre-publish test, and nothing else.
npm run check 0 errors · web unit suite 1608 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): flip the quick-actions footer line when the answer comes due (codex round 3)
Round 3's finding, and it is a self-inconsistency round 1 introduced. The
click-time expiry check made the ROUTING correct immediately, but the footer
line still read raw `presence` until the next 10s poll tick — so for up to a
full interval the menu said "Pushes to your connected agent session" while the
next click would copy. That line's entire job is to say what the next click
will do.
A successful read now arms a one-shot timeout at the exact expiry, so the
display flips when the answer comes due rather than when a poll happens to
notice.
`currentPresence()` STAYS, and the redundancy is the point: a timer is a
request, not a guarantee. Browsers throttle timers hard in a backgrounded tab,
so the expiry can fire long after it came due — including after the user has
returned and clicked. The timer keeps the DISPLAY honest; the click-time check
keeps the DECISION correct, and only the decision can lose a message. The poll
tick keeps its expiry check for the same reason.
Both halves mutation-tested, and they fail different tests: dropping
`armExpiry()` fails ONLY the comes-due test; dropping `currentPresence()` at
the click fails ONLY the throttled-timer test. That second test models
throttling by moving the CLOCK without running any timer — which is exactly
what a throttled tab looks like from the component's side, and is not
reachable with advanceTimersByTime.
npm run check 0 errors · web unit suite 1609 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
79b3220c61 |
test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570) (#1102)
* test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570) The reload-fault closure now narrows the member's access on faulting tick 1 and lifts the fault on faulting tick 2, so consecutive reload failures stop at exactly 2 — strictly below the clear-the-watch-set bound — and the green path carries no timing bet at any load. The 300ms sleep is gone; readiness is signaled by the tick sequence itself. Codex round 1 on this fix surfaced that regression DETECTION still has a window (a successful tick 3 masks a hypothetical reset-skipped-on- fault regression), so the interval is set to 500ms to give the revoked PATCH ~10x headroom over measured loaded-runner request latency, and the control-leg wait — the one that timed out in both CI instances — is widened to 10s since it asserts delivery-at-all, not latency. Verified: 5x -race green at both 50ms and 500ms; counterfactual mutant (reset moved to the reload success path) leaks 3/3; full suite + lint green; Postgres leg 2x -race green. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * test(server): drive reval ticks through a seam — deterministic in both directions (BUG-2570) Codex rounds on the first fix found two regression-DETECTION windows the interval-tuned shape could not close: a stray successful tick before fault installation or after the tick-2 lift resets visCache / reloads the watch list, masking the reset-skipped-on-fault regression this test exists to catch. Interval tuning trades green-determinism against detection-determinism; a free-running ticker cannot give both. So the handler gains watchRevalTickOverride — a test seam mirroring watchPredicatesLoadFault (atomic pointer, read once at stream setup) that lets a test substitute the reval tick source. The test now drives exactly ONE tick, after the access change, with the reload fault active: no early tick can mask via a pre-fault reset, no late tick can mask via a post-lift reload, and one faulting tick can never reach the clear-the-watch-set bound. No sleeps, no interval mutation, no wall- clock bets in either direction. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
2c5803a204 |
fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) (#1101)
* fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) A page reads its items and only then subscribes to SSE. A mutation landing in that window reaches nobody: no subscription exists yet so the frame is never received, and nothing reconciles the gap afterwards — the row stays stale until some unrelated event happens to trigger a sync. `pendingSyncOnConnect` is exactly the right mechanism and already existed, but was armed only on leader promotion and the lock-failure fallback. The FIRST connect — the one every page load performs — was uncovered. Arms it on every EventSource open instead. Cursor-advance semantics are untouched: the delta is asked from whatever cursor syncService already holds. IDEA-2535 owns that question. Also removes the "first leader vs promoted follower" classification (a `navigator.locks.query` probe plus a >100ms grant-delay heuristic from TASK-1359 rounds 2-3). It existed solely to gate this flag; with the flag armed unconditionally nothing reads it, and keeping it would mean a `locks.query()` round-trip per connect producing a value no one consumes. Strictly more coverage, not a trade — every case it classified as "promoted" still arms. VERIFICATION — and three instruments that did NOT discriminate before one did: - Unit tests (5) pin the mechanism: armed on the lock path, on the no-leader-election fallback, dispatched AFTER open rather than before (the TASK-1359 round-4 ordering property this must not lose), claimed once across the onopen/`connected` arms, and re-armed for the next connect. Reverting the fix reddens all 5. - Collection page + "is the row visible": CANNOT discriminate. That page runs its own deltaSync on mount, which covers the same window either way. Fixed and unfixed both "passed". - Graph page + "is the node's title in the page text": BLIND. A positive control — item created with no race at all — is also "not present", so every reading was measuring nothing. - Graph page + /graph response bodies, natural timing: still cannot discriminate. The write consistently lands before the page's own first read, so nothing is ever missed and both builds "recover". - What finally worked: builds with the EventSource open delayed 3s so the window is wide enough to aim at, write timed into it. 3/3 LOST on unfixed, 3/3 RECOVERED on fixed, both binaries confirmed serving and differing only in the arming sites. One hypothesis was refuted along the way rather than written down as fact: I suspected the graph subscribed too late to receive the connect dispatch. An instrumented run showed `dispatchSyncRequired subscribers=2` — both syncService and the graph are registered before it fires. The real reason those runs failed was that a stale server process was still bound to the port, so they were served by an unrelated binary. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * fix(web): fence the connect-sync against a stale EventSource (codex review) `pendingSyncOnConnect` is shared state and the open/connected handlers closed over no identity, so on a fast workspace switch source A's already-queued handler could still run after B existed — clearing the flag and broadcasting on B's channel, at which point B's own open would find it false and SKIP the sync it needed. `close()` does not retract queued event tasks, so this is reachable. It would silently reopen the exact gap this branch closes, on the switch path where a fresh read is most likely to be stale. Each handler now returns unless its own source is the current one. Those guards also stop a torn-down source writing `status` / broadcasting, which they never did before. Listener registration uses the local `source` throughout rather than re-reading module state (behaviour-identical; the two are the same object at registration). `disconnect()` also clears the flag. Labelled in-code as belt-and-braces rather than implied load-bearing: mutation-testing shows removing that line ALONE changes nothing observable, while removing the identity guards reddens the stale-source test. Kept because leaving per-connection state set after the connection is gone is how this bug arose. Three tests added, and the mutation testing is worth recording because the first attempt was a false green: dropping the identity check inside `claimPendingSync` reddened NOTHING, since the handler-level guards still caught it — a layered-guard mask. Only removing every guard isolates which layer acts. The tests now discriminate at that granularity. Also covers the lock-failure fallback arming path, which had no test at all. Codex's other finding — follower tabs have the same uncovered window and cannot use this mechanism, since they never open an EventSource — is real and filed as BUG-2576, along with the adjacent unguarded listeners this commit had no reason to touch. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd |
||
|
|
403a6de19d |
docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541) (#1100)
* docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541) `skills/pad/SKILL.md` — the //go:embed source `pad agent install` writes into user projects — had TASK-2537's minimal safety note but not the structured branch the plugin copy carries. Ports it, minus the Claude-Code-specific shell advice (the embed source also serves Codex, Cursor, Windsurf, OpenCode and pure-MCP agents with no shell at all), so it now names the two stderr signatures as separate cases with opposite handling. Also adds the Onboarding routing entry's missing precondition. It sent the agent to load the onboard playbook, which lives IN a workspace — so on the unlinked path `pad playbook show onboard` fails exactly the way bootstrap just did, and the entry routed into a dead end. Ride-along from the lead's citation sweep: the stale "hangs indefinitely / no timeout" wording still shipped in plugin/skills/onboard/SKILL.md and plugin/skills/pad/SKILL.md (two places). All three now carry the bounded wording. RE-VERIFIED RATHER THAN INHERITED, per this item's own requirement — and the inherited correction needed one too: - Read the code myself. First-admin setup is capped at 20m (bootstrap.go::bootstrapPollTimeout). The auth poll (cmd_auth.go::pollAndSaveCLIAuth) has NO wall-clock limit of its own: it exits only on ctx.Done, `approved`, or `expired`, and `continue`s past transient errors. So the ~5m bound is entirely the server-side session TTL (cli_auth_sessions.go::cliAuthSessionTTL) — if the server becomes unreachable after the session is created, it polls forever. "Bounded, not indefinite" is right for the ordinary case and wrong for that one; the skill text now says both. Filed separately. - Observed it, not just read it. On a configured-but-unauthenticated HOME, `pad workspace init` printed the browser URL and was still waiting when a 25s cap killed it. `pad auth whoami` returned in 0.106s in that state AND in the unconfigured one, with distinguishable output — the "fast, safe" claim the whole branch rests on. - The exact stderr strings were wrong in both copies: the not-configured case has NO `Error:` prefix (`Pad is not configured. Run 'pad auth configure' first.`), only the unlinked one does. Corrected from captured output. Verified through `pad agent install claude` into a scratch project and read back off the installed file, not the diff. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * docs(skill): `pad auth whoami` blocks in a TTY when unconfigured — qualify the claim (codex review) Both plugin copies said `pad auth whoami` "never blocks waiting on input". It does, in one state: `whoamiCmd` → `getConfiguredConfig()`, and on an unconfigured machine that enters the interactive configure flow whenever `canPromptForConfig()` is true — i.e. stdin AND stdout are both terminals (configure.go:357). My own measurement (0.106s) was non-interactive, so it could never have caught this; Codex found it by reading the call path. The embed copy already had the right qualification ("in non-interactive use it returns immediately"); this brings the two plugin copies in line and says why the qualification is the operative one for an agent. Codex's other finding — that the embed source's opening still frames `/pad` as THE command, for surfaces with no slash commands — is real but pre-existing and editorial rather than part of this port. Filed as BUG-2573. The auth-poll timeout gap found while re-verifying the hang is BUG-2572. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * docs(skill): `pad init` does not fail fast either — correct both plugin copies (codex round 2) Both plugin copies told agents that in the configured-but-unauthenticated state, `pad init` "fails fast" and merely "needs a TTY to complete" — offered as the contrast to `pad workspace init`'s browser-poll block. It is false. `cmd/pad/init.go`'s Step 4 calls `doBrowserLogin` with no TTY guard when the server is already initialized but the client isn't authenticated. Observed: on a configured-but-unauthenticated HOME, non- interactive, `pad init` printed the same browser URL and was still waiting when a 20s cap killed it — identical to `pad workspace init`. That parenthetical was the one thing in the paragraph that could have made an agent run a command instead of handing back, so it was the worst line to have wrong. Both copies now say it is no safer as a probe. Third claim in these three files this task that was wrong because it was reasoned rather than run — the first two being "hangs indefinitely" (bounded, mostly) and "whoami never blocks" (it prompts in a TTY). Codex's other two round-2 findings are real but out of this port's scope and filed: BUG-2574 (the plugin onboard skill inlines its own script instead of loading the canonical onboard playbook) and BUG-2575 (plugin decompose entries omit SPEC targets the embed source and playbook both support). Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * docs(skill): narrow the `pad init` claim to the state it is actually true in (codex round 3) The previous commit replaced one over-broad claim with the opposite one. "Fails fast" was wrong for the configured-but-unauthenticated case; "no safer as a probe, blocks identically" is wrong for the genuinely-unconfigured one. Both measured, non-TTY, same binary: unconfigured → 0.106s, "Error: Pad is not configured..." configured-but-unauthenticated → browser URL, still waiting at 20s The instruction ("don't run it yourself") was right in both readings, so this is the justification being wrong rather than the advice — which is exactly the failure mode this whole item is about, and I reproduced it while fixing it. Both plugin copies now say which state each behaviour belongs to. Codex's other round-3 finding — that the plugin onboard skill gates its recovery branch on `.pad.toml` being absent, so a STALE link skips it entirely and dead-ends at the same bootstrap failure — is real and is the pre-link half of the same skill's problem. Added to BUG-2574 rather than widened into this port. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd |
||
|
|
e03ba45b5c |
feat(web): push-to-agent composer in the item view (TASK-2561) (#1099)
* feat(web): push-to-agent composer in the item view (TASK-2561)
PLAN-2558 S3 — the web half of IDEA-2544's push-to-harness. Adds
`api.items.push`, a new `api.sessions.list`, and a "Push to agent…" row
in the item pane's ⋯ menu that opens a small composer.
The deliverable is the presence line, not the textarea. `pad push` is
fire-and-forget — no durable inbox, no ack, no "nobody was listening"
warning — which is defensible for a CLI verb typed by someone who knows
their own session is running, and indefensible for a button. So the
dialog answers "is anything listening?" before the click, and keeps
three states apart rather than two:
N > 0 send, worded "N session connected", never "will be
delivered" — the registry can name a session that died up
to ~30s ago and no push gets a receipt
N == 0 send DISABLED. Nothing listening means the message is
lost, not queued; the empty state offers the clipboard
instead (the fallback S4 rules for quick actions)
can't tell send ENABLED, uncertainty stated. A 503/401/network
failure is not zero — rendering it as zero is the exact
lie handleListSessions returns 503 rather than an empty
list to avoid
The menu row is gated on a resolved user, not on canEdit: push is
self-addressed, so a viewer pushing an item into their own session is a
read. Without a user the endpoint 401s.
$lib/push/message mirrors the server's rune-after-collapse accounting so
an over-length message is caught in the composer instead of coming back
as a 400. It deliberately does not use JS `\s`: Go's unicode.IsSpace and
`\s` disagree in both directions (U+0085 is whitespace to Go only,
U+FEFF to JS only), so a `\s` client under-counts a pasted BOM and
over-counts a pasted NEL. The agreement is pinned by a shared fixture
(internal/server/testdata/push_message_cases.json) read by BOTH
internal/server/push_message_collapse_test.go and the web unit test — a
TS-only table would assert a belief about Go rather than Go's behaviour.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): close the push composer's races and ambiguity gaps (codex review)
Round-1 review findings on the S3 composer, all real:
- ItemDetail did not reset `pushDialogOpen` on an item switch. The dialog
is {#key itemSlug}-remounted while `open` is owned by the parent, so a
stale `true` silently REOPENED the composer pointed at the new item.
The reset block's existing comment (written for copyDialogOpen)
describes this exact failure. Verified live, with the counterfactual:
reverting the one-line fix reopens the dialog on item B after a
client-side navigation. (The typed draft does NOT carry over — the
{#key} remount clears it — so the defect is the silent reopen, not a
retargeted message.)
- Presence polls shared one generation counter, which fences OPENINGS,
not requests. A stalled poll could resolve after a later one and
overwrite a fresh count with a stale one, re-arming Push against a
session list already known to be empty. Added a per-request sequence;
only a strictly newer response is applied.
- Nothing bounded a `/sessions` read, and 'checking' disables Push, so a
request that never settled stranded the composer with a dead button and
no explanation. It now degrades to the honest "can't tell" state after
5s; a later response still lands and upgrades the answer.
- A failed send re-armed Push unconditionally. The handler publishes
BEFORE writing its response, so an unstructured failure (rejected
fetch, non-JSON 502) leaves the outcome genuinely unknown and a second
click can deliver the instruction twice on an endpoint with no
idempotency key. Split on the same line CopyItemDialog draws (DR-13):
a structured PadApiError means the server refused before publishing —
re-arm; anything else latches an outcome-unknown state.
- `willCollapse` compared against `String.trim()`, reintroducing the very
JS-vs-Go whitespace mismatch $lib/push/message exists to avoid (JS
trims a leading U+FEFF the server keeps; it leaves a U+0085 the server
strips). Added `trimPushMessage`, which trims with Go's class.
- The textarea described only the counter, so the collapse note and the
over-length error reached no screen reader. Both now live in one stable
referenced node that swaps text rather than mounting and unmounting —
an aria-describedby pointing at an absent id resolves to nothing.
- Positive presence wording implied the count was current. It now says
"as of the last check" and names the ~30s window.
Test changes: the Go fixture test duplicated `strings.Fields` rather than
invoking the handler, so a change to the handler's normalization would
have left BOTH suites green — demonstrated by mutating the join
separator, which the copied-expression test did not notice and the new
handler-driven test caught on 22 cases. The bound is likewise now
asserted through the endpoint at 4096/4097 instead of comparing the
constant to a copy of itself.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): fence the push composer against destroyed instances, unrecognised errors, and a frozen count (codex round 2)
Three findings, one of them introduced by round 1's own fix:
- The send/copy continuation fence used the generation counter, which
cannot see a keyed REMOUNT. `{#key itemSlug}` gives item B a fresh
instance with its own counter, so item A's in-flight send still saw its
own `gen` unchanged and called the SHARED parent `onclose` — closing the
composer the user had just opened for B. Added a per-instance
`destroyed` flag, which is what actually distinguishes "still mine to
close" from "I no longer exist".
- The outcome-unknown split treated any PadApiError as proof the server
refused before publishing. It isn't: the API client turns EVERY JSON
error envelope into one, including a gateway 5xx invented after the
handler published. Replaced with a whitelist of codes the handler and
its middleware actually emit pre-publish; everything unrecognised is
now ambiguous. The asymmetry is deliberate — an unnecessary "we can't
tell" costs the user a check, a wrong re-arm delivers twice.
- PRESENCE_STALL_MS only rescued the FIRST read. A later poll that hung
froze the count at its last value indefinitely while the UI kept
rendering "1 session connected" as fact. A known answer now expires to
"can't tell" after 30s without a refresh — the server's own presence
staleness bound, so past it our answer carries no more authority.
Also dropped the status→alert role swap on the composer's live region:
changing a live region's role and its text together is not reliably
honoured, so the escalation was a promise the markup couldn't keep. The
blocking condition rides `aria-invalid` on the textarea instead.
The "latest ARRIVED, not latest ISSUED" behaviour of the sequence fence
is kept and now documented as a choice: dropping an early-arriving
response because a newer request exists strands the UI when that newer
request is the one that never settles.
Each fix mutation-tested 1:1 against its new test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): complete the pre-publish whitelist and retire in-flight polls on expiry (codex round 3)
Two of round 3's three findings were real:
- `csrf_error` and `email_not_verified` are middleware refusals, written
strictly before the handler runs, so they belong in
PRE_PUBLISH_ERROR_CODES. Without them a CSRF mismatch told the user we
couldn't tell whether their message was sent, when nothing had been.
- The 30s staleness expiry didn't fence requests already in flight. A
poll issued before the expiry could land after it and reinstate the
very count we had just declared too old to trust. Expiry now advances
`presenceAppliedSeq` to the current `presenceSeq`, retiring those
responses; the poll issued in the same tick carries a newer seq and
still applies.
The third finding — that `archived` belongs in the whitelist, and that
the launcher should be hidden for archived items because "the endpoint
always rejects them" — is REFUTED. handlePushToItem has no archived gate
(`requireItemVisible` admits archived items), and pushing to an archived
item against a running server returns 200 with `pushed: true`. There is
no `archived` error code on this path to whitelist, and hiding the
launcher would remove a capability that works. Recorded rather than
silently skipped so the next reader doesn't re-derive it.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
7943234dd7 |
fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566) (#1098)
* fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566)
PATCH with {"assigned_user_id": ""} 500'd with a raw FK constraint
error and left the item assigned. An explicit empty string is what a
JSON client sends when a user blanks the field (JSON null on a *string
decodes to nil = "don't change", so it can't express clearing), and
validateAssignmentScope already skips "" as nothing-to-validate — the
SQL builder just never learned the same convention and bound it
verbatim into the FK column.
Store-level fix so every surface (HTTP, bulk, MCP, CLI) inherits it:
"" now clears assigned_user_id / agent_role_id exactly like
ClearAssignedUser / ClearAgentRole on update, and binds NULL on
create. The mutation signal keeps the ClearAssignedUser shape (tested)
so watch notifications are unaffected. parent_id deliberately NOT
given the same coercion: parent relations also live in item_links, and
clearing the column alone would desync them.
Web UI is unaffected either way — it already sends
clear_assigned_user: true; only API clients hit this.
Tests reproduce the exact FK failure pre-fix (verified by stash-run)
and pass on both SQLite and PostgreSQL post-fix.
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
* fix(store): relocate nullIfEmptyID out of createItemTx's doc comment
Codex round 2 P3: the helper was inserted between createItemTx's doc
block and the function, orphaning the doc comment.
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
|
||
|
|
b9381bf5f1 |
feat(cli): markdown output on the remaining list surfaces; broaden ANSI stripping (#1080)
Completes #898 and fixes #1076. Markdown on the seven surfaces left out of #1070, so `--format markdown` is now honestly global and the flag help collapses to "table, json, markdown": - `item comments`, `item deps`, `project activity`, `attachment list`, `library list`, `role list`, `workspace members`. Two of those are not tabular, and markdown follows the terminal shape rather than forcing a table onto them: - `item comments` keeps the attribution-line-then-body form, and the body is emitted VERBATIM. A comment body is authored as markdown; escaping it would turn its lists and code fences into literal text. Only the attribution line, which we construct, is sanitized. - `item deps` keeps its two sections as `## Blocks` / `## Blocked by` lists. Colour carried the direction in the terminal (yellow out, red in); headings carry it here. New shared spine: `cli.RenderMarkdownTable(w, headers, rows)`. Every cell is escaped, and ragged rows are padded or truncated to the header width so a short or long row can't shift the column count and break the table. Wiring a surface is now naming columns and mapping rows. #1076 — ANSI stripping covered only SGR (`ESC[…m`), so non-SGR CSI sequences, OSC-8 hyperlinks, and stray C0 controls survived, both in the table width maths and in markdown output whose doc comment promised escape-free text. Replaced `sgrPattern` with `ansiPattern` + `stripANSI` covering OSC, CSI, two-character Fe escapes, and stray C0/DEL, with TAB/LF/CR deliberately preserved for callers that normalize them. `displayWidth` now uses it too: a control sequence is zero-width, so counting it was a column-alignment bug of the same family. Tests: 12 stripping cases, 4 table-helper cases (including ragged rows), 4 renderer cases for the two non-tabular surfaces, and the routing test extended to 8 subtests — one per surface, driven through cobra against an httptest server. Also covers the two gaps named in #1076: `item starred` and the scoped `item list <collection>` path. Each new guard was proven by mutating the source and watching it fail, not just by passing. Gates: go build ./... PASS; go vet PASS; gofmt clean; golangci-lint 0 issues. Both touched packages show the same 6+2 pre-existing Windows failures as clean main under an identical sandboxed run. |
||
|
|
ad1e919290 |
chore(deps): bump otel exporters to v1.45.0, clearing GO-2026-4985 from the Nix baseline (#1097)
* chore(deps): bump otel exporter cluster to v1.45.0 (GO-2026-4985) Clears GO-2026-4985 (otlptracehttp oversized response bodies, fixed v1.43.0) from the Nix artifact's accepted-advisories baseline. The whole cluster is transitive — pad has no direct otel usage; it arrives via fosite → ory/x → otelx, and fosite's latest (v0.49.0, already pinned) still requires the vulnerable exporter, so MVS override is the only path. Pulls otel core/metric/sdk/trace v1.44→v1.45, proto/otlp v1.0.0→v1.11.0, grpc v1.82.1→v1.83.0, genproto refresh. The jaeger exporter stays at v1.17.0 (its final release) and coexists. BUG-2085 deferred this bump pending a blast-radius assessment; the assessment is this diff, measured: go build ./..., go vet, full SQLite test suite, and golangci-lint all green; artifact-faithful proxy scan (GOTOOLCHAIN=go1.26.5, -s -w) reports 9/9 accepted with no new advisories. Remaining baseline: 8 stdlib (nixos-26.05 backport) + openpgp (no upstream fix exists). vendorHash refresh follows in the next commit via the PR's Nix CI run. Refs BUG-2085, BUG-2567. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * chore(nix): refresh vendorHash for the otel exporter bump Same flow as #1096: value from the PR's own failed Nix CI run. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
ff201939b9 |
chore(deps): bump x/image to v0.45.0, clearing GO-2026-6222 from the Nix baseline (#1096)
* chore(deps): bump golang.org/x/image to v0.45.0 (GO-2026-6222) Clears GO-2026-6222 (VP8L decode memory allocation) from the Nix artifact's accepted-advisories baseline — the advisory's fixed version is exactly v0.45.0. Pulls x/text v0.41.0, x/mod v0.38.0, x/tools v0.48.0 as transitive requirements. Verified against a build-faithful proxy (GOTOOLCHAIN=go1.26.5, -s -w): scan reports 10/10 accepted, no new advisories, no prune warnings. Full SQLite test suite and golangci-lint clean locally. nix/package.nix vendorHash refresh follows in the next commit, using the PR's Nix CI job as the builder (no local nix; the flow is the one package.nix documents). Refs BUG-2085, BUG-2567. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * chore(nix): refresh vendorHash for the x/image bump Codex round 1 P1: go.sum changed, so buildGoModule's fixed-output vendor derivation no longer matches the pinned hash. Value taken from the PR's own failed Nix CI run (the got: line), per the regeneration flow package.nix documents. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
cfad8d989e |
ci(nix): gate the Nix-built binary with govulncheck (BUG-2567) (#1095)
* ci(nix): gate the Nix-built binary with govulncheck (BUG-2567) The main CI govulncheck job scans a go-built binary, which honours go.mod's toolchain line — so the Nix artifact (GOTOOLCHAIN=local in nixpkgs, go 1.26.5 until nixos-26.05 backports 1.26.6) shipped with no vulnerability gate over it at all. Add nix/vulnscan.sh: binary-mode govulncheck against result/bin/pad, compared to nix/accepted-advisories.txt. Known advisories stay green and recorded in-repo; any NEW advisory fails the Nix job; a cleared advisory emits a warning annotation so the list gets pruned and BUG-2567 closed when the backport lands. The accepted list carries 11 entries, measured against a build-faithful proxy (GOTOOLCHAIN=go1.26.5, CGO_ENABLED=0, ldflags "-s -w"): the 8 reachable stdlib advisories from BUG-2565, plus 3 module-level entries that only appear because -s -w strips the symbols govulncheck needs for call-graph precision — a symbol-precise scan of the same source shows all three uncalled. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * ci(nix): guard vulnscan against empty or non-binary govulncheck output Codex round 2: an exit-0 govulncheck run that produced empty, truncated, or garbled JSON — or silently ran in a mode other than binary — was indistinguishable from a clean scan. Assert the stream's config message reports scan_mode=binary and make both jq extractions fail closed (exit 2, operational error). Also sharpen the accepted-list comment on the three module-level entries: on the stripped artifact govulncheck reports them as affected with symbol frames (it cannot prune the call graph, so every vulnerable symbol of an imported package counts as potentially called); the round-2 reading of "degrades to module-level reporting" as functionless findings was wrong, verified against the actual JSON stream. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
c84cf7437c |
feat(sessions): announce session identity on the event stream (PLAN-2558 S2, TASK-2560) (#1094)
* feat(sessions): announce session identity on the event stream (TASK-2560) PLAN-2558 S2. S1 gave the presence registry a count of anonymous uuids; this makes each row nameable, which is what S3 needs for an honest empty state and S5 needs for a target picker. A monitor now announces itself when it opens the stream: X-Pad-Session-Label (the working directory's basename) and X-Pad-Session-Pid. The server sanitizes both and stores them on the LiveSession; GET /api/v1/sessions returns them. TRANSPORT. The task body sketched "the stream connect carries it" without picking a mechanism and explicitly left the call open. Headers, because a query param would put the label and pid into every access-log line (this server logs path= for each request) and any proxy log in front of it — which is the same "don't let local detail travel further than it needs to" the privacy line below is about — and a separate registration POST would need its own correlation to the connection it describes, plus a matching lifecycle, when the registry entry already lives and dies with the stream. Headers ride the request that exists and sit alongside Last-Event-ID, already doing this job on this endpoint. Cost, written into the code rather than discovered later: a browser EventSource cannot set headers, so a future web-tab consumer needs a deliberate query-param fallback or a fetch-based SSE reader. PRIVACY. The basename crosses, never the full cwd — "/home/dave/Dev/ docapp" additionally hands over a home directory and usually an account name for no gain — and messaging_socket_path never leaves the machine. Pinned by a test rather than by the implementation being one line. WHAT THIS DELIBERATELY DOESN'T DO: read ~/.pad/sessions/. The task framed S2 as giving `pad session register` its first consumer, and the monitor cannot honestly be one. Registry entries are written by whatever process ran that command — a different pid — and the only matchable fields are pid and cwd, so two agent sessions in one checkout are indistinguishable and "pick the newest" is a coin flip that would put a confident wrong name in the S5 picker. Process ancestry settles it exactly and is platform-specific (this binary ships for macOS and Windows). The monitor's own cwd basename and pid are never wrong and answer the question the label exists to answer; correlating a stream to the agent session that spawned it needs an identifier the harness passes down, which is worth doing when something needs it and worth not faking until then. Also moves S1's STALENESS doc block, which sat above LiveSession.Label where it read as documenting the name rather than the whole entry. Tests: sanitizer units (whitespace collapse, control-char stripping, rune-not-byte truncation), header wiring, the end-to-end labelled session, the unannounced-client compatibility leg (a pre-S2 monitor must still register and still stream), a hostile-input leg over the wire, the client's omit-when-unset behaviour, and the basename promise. Measured rather than assumed: Go's server answers 400 to a header value containing a control byte before any handler runs (verified with a raw socket, since Go's own client refuses to send one and the two refusals are indistinguishable from a normal client test). So that arm of the sanitizer is unreachable over HTTP; it stays as defence in depth for the next caller in, and both the comment and the wire test say so instead of the test quietly passing because the transport refused the input. Mutation-tested four ways, each revert grep-verified: handler ignoring the parsed identity, monitor sending the full cwd, dropping the truncation, and the client always setting the headers. Refs TASK-2560, PLAN-2558 * fix(cli): sanitize the session label client-side per Codex review (round 1) Codex round 1's only finding, and it is a bigger deal than a missing label. Unix directory names may contain control bytes — "doc\napp" is a legal directory — and Go's http.Client REFUSES to send a request whose header value holds one: Do returns "invalid header field value" and nothing is transmitted. In the monitor that is indistinguishable from an unreachable padd, so the retry loop backs off and tries again, forever, printing nothing by contract. A user who named a directory that way would simply stop receiving notifications, with no signal anywhere. The server cannot defend against a request that never arrives. Reproduced before fixing, with a real directory and a real client, rather than reasoned about from the error message. Sanitizing in NewWatchEventsStreamRequest rather than in monitorSessionIdentity: the invariant is "this function never builds an unsendable request", which belongs at the point where a value becomes a header, not at one caller. The client's cap (256 runes) is deliberately looser than and independent of the server's (64): the server decides what a label should look like, the client only has to keep the request sane, and neither has to track the other to stay correct. The regression test does the ROUND TRIP instead of inspecting the header, because the header contents were never the bug — http.Header.Set stores anything, so an assertion on the value passes against the broken version too. Only attempting the request tells the two apart. Mutation-verified: reverting the sanitizer fails the test with exactly the "invalid header field value" error from the field report. |
||
|
|
8af62d1c4e |
fix(ci): build with go1.26.6 to clear the govulncheck gate (BUG-2565) (#1093)
* fix(ci): build with go1.26.6 to clear the govulncheck gate (BUG-2565)
main has been red since
|
||
|
|
599fdbd3f4 |
feat(watch): drop assignment from the addressed-to-you stream (IDEA-2544 Phase 2, TASK-2551) (#1092)
* feat(watch): drop assignment from the addressed-to-you stream (TASK-2551) IDEA-2544 Phase 2. Assignment is bookkeeping (who owns this); push is dispatch (where attention goes now). Conflating them meant one triage session assigning N items sprayed N notifications into every open session of the assignee, so Dave's product call (day-33) was to drop assignment from addressed-to-you entirely — no opt-in flag, no config key. watchNotificationVisible loses its KindAssignment early-return; an assignment notification now falls through to the watch-map check like any other item-level fact, which is what an unconditional watch already promises to deliver. Producers are untouched and AssignedUserID is still populated, so a future opt-in re-addressing would be a consumer-side change only. KindPush is now the only addressed kind. Tests: six tests rode the deleted path and are reworked, not deleted. The two mid-stream visibility tests needed new vehicles — the persistent-reload-failure test uses a push (same watch-map-independent property), and the reval-ordering test uses collection-access revocation with a still-granted control item, since push is self-addressed only and its subject is a user losing access. That test's reval interval goes 50ms -> 200ms: at 50ms the clear-the-watch-set bound landed ~30ms behind the assertion and the control leg lost the race. New coverage for the asymmetry the change creates: a push stays exclusive of watch-matched delivery, an assignment does not — a watcher is entitled to see who an item was assigned to. Mutation-tested three ways (restore the old branch; make assignment exclusive addressed-only; couple visCache.reset() to reload success); each is caught by the intended test and each revert was grep-verified. Live: assigning a fresh unwatched item to the connected user leaves the plugin monitor silent, pushing the same item prints one line, and assigning a WATCHED item still delivers — verified end to end against a sandboxed server, not just in tests. Refs TASK-2551, IDEA-2544 * docs(watch): note the deferred plugin wording per Codex review (round 1) Codex's only finding: plugin/monitors/monitors.json and plugin/skills/pad/SKILL.md still describe assignment as addressed-to-you traffic. Correct observation, deliberately out of scope — installed plugins are version-pinned at install, so plugin-visible text reaches nobody without a version bump, and TASK-2564 (PLAN-2558 S6) owns the wording and the bump together. Recording it in code next to the deleted branch rather than leaving a reader to discover the mismatch, and on TASK-2564 with the exact line refs so the follow-up does not have to re-find them. |
||
|
|
21001bc4c3 |
feat(sessions): session-presence registry + GET /api/v1/sessions (PLAN-2558 S1) (#1091)
* feat(sessions): session-presence registry + GET /api/v1/sessions (PLAN-2558 S1)
Slice 1 of PLAN-2558 (IDEA-2544 Phase 3, web-UI push). The server can now
answer "is anything actually listening right now?" for the calling user.
WHY. `pad push` (Phase 1,
|
||
|
|
da6ce642da |
feat(push): pad push — user-authored instruction dispatch to agent sessions (IDEA-2544 Phase 1) (#1090)
* feat(push): add pad push <ref> -m vertical (IDEA-2544 Phase 1)
Self-addressed, human-to-harness dispatch over the existing watch-events
bus/stream: CLI -> POST .../items/{itemSlug}/push -> a new KindPush
Notification (carrying the generalized TargetUserID addressed-to field
KindAsk will later share) -> watchNotificationVisible delivers it back
to the pushing user's own connected monitor sessions. Transient,
fire-and-forget by design (no migration, no durable inbox) since
assignment already covers the durable-notification case and this is
meant to be the explicit, no-inference dispatch verb instead.
* docs(plugin): document the push notification contract (IDEA-2544 Phase 1)
Push is the one notification kind that IS an instruction rather than a
passive fact, so it gets its own lead bullet in the plugin skill's
notification-etiquette section (ahead of the read-only/park default,
which it explicitly lifts) and a mention in the monitor's description.
The embed-source skills/pad/SKILL.md has no notification section to
mirror this into (the two files diverge by design) and is left
untouched.
* fix(push): reject over-long push messages instead of unbounded Summary
Comments truncate their notification Summary to a preview (the full
body is still fetchable), but a push message IS the payload — silently
truncating it would corrupt the instruction with nothing to recover it
from. Add maxPushMessageLen (4096, measured post-collapse) and reject
anything over it with a 400 rather than truncating; state the same
bound in `pad push --help` so it's discoverable before a 400, not only
from one.
* fix(push): close the watch-fallthrough leak, disambiguate SKILL.md exceptions
Codex round 1 P1: watchNotificationVisible's push branch only returned
early on a MATCH — a non-target caller fell through to the watch-map
check below it, so anyone holding an unconditional (or predicated)
watch on the item received every push addressed to every OTHER user,
instruction text included. Push is addressed private dispatch, not an
item-level fact watchers have a legitimate claim on (unlike assignment,
which watchers are expected to see per `pad watch --help`) — the branch
now returns unconditionally for KindPush, gating strictly on
TargetUserID and never reaching the watch-map fallback either way.
Pinned explicitly since Phase 4's session targeting is expected to
inherit this same exclusivity.
Also (codex P2): reworded the SKILL.md notification-etiquette bullets —
the new push exception and the pre-existing assignment/ask exception
literally contradicted each other ("the ONE narrow exception" claimed
singularity after push had already claimed exception status). Now
explicitly enumerated as the first and second exceptions to the
never-write rule.
* test(cli): pin that PushItem inherits X-Pad-Agent (BUG-2542 rebase)
Verified, not assumed: PushItem builds its request via c.post ->
c.newRequest like every other mutating client method (CreateWatch
included), so the attribution fix's client.agentName wiring covers it
for free with zero code changes needed on this branch. Adds a live
httptest assertion rather than trusting the code-path read alone —
the same shape as TestClientSendsResolvedAgentHeader, scoped to
PushItem specifically since that's the one method this PR added.
* fix(push): disambiguate workspace in the monitor line and skill contract
Codex round 2 P1: the watch-events stream is user-scoped ACROSS every
workspace a caller has watches in, but formatMonitorLine printed only
ItemRef/Kind/Actor/Summary and dropped the Workspace field the wire
payload already carried — a session linked to workspace A receiving a
notification for workspace B would resolve the wrong item (or 404) with
no signal in the line that anything was off.
Fixed universally, not push-only: grepped plugin/ and skills/ for
anything parsing "PAD ..." lines and found none — the Claude Code
plugin host ingests the stdout line as free-text notification prose,
formatMonitorLine's only real consumer is its own fmt.Println, so there
is no wire-format consumer a workspace prefix could break. The
ambiguity predates push (any watched item across workspaces already had
it); push just makes the consequence sharper because it carries an
instruction rather than a passive fact.
SKILL.md's push bullet now tells the agent to resolve with
`pad --workspace <workspace> item show <ref>` using the slug read off
the notification line, not a bare `pad item show <ref>`.
* fix(push): respect --format json instead of hardcoding plain text
Codex round 2 P2: pushCmd's RunE ignored the global format flag and
always printed "Pushed <ref>", silently discarding --format json.
- server.pushResponse replaces the bare map the handler wrote before —
a typed {ref, workspace, pushed, message} shape, with workspace
resolved to the CANONICAL slug via s.getWorkspace (not merely echoed
from whatever the URL contained), matching the same disambiguation
need the round-2 P1 fix addressed for the monitor line.
- cli.PushItem now returns (*PushResult, error) instead of discarding
the response body.
- pushCmd checks formatFlag == "json" and calls cli.PrintJSON, mirroring
runCreateWatch's existing pattern.
internal/cli/agent_identity_test.go's TestPushItemSendsResolvedAgentHeader
needed a one-line update for PushItem's new two-value return — caught by
`go vet ./...`, not `go build ./...` (which doesn't compile test files);
folding vet into my own pre-flight going forward.
|
||
|
|
f9195c5b09 |
ci: make the go test timeout explicit everywhere (TASK-2545) (#1089)
* ci: make the go test timeout explicit everywhere (TASK-2545) The v0.13.0 release pre-flight died on `panic: test timed out after 10m0s` in internal/store, on a commit whose Go tree was identical to a green run an hour earlier. Nothing hung — the package's runtime simply crossed a budget nobody had chosen. `go test` without -timeout uses a 10m per-test-binary default. This repo raised the two RACE steps to 45m twice as the suite grew (BUG-1371 30m, BUG-1913 30m→45m), each time with a careful comment — and each time left their non-race siblings on the silent default. Three steps were still running on it, including the release gate: ci.yml "Run tests" (SQLite) ci.yml "Run tests against PostgreSQL" (the one that panicked) release.yml "Run tests" (the release gate itself) All three now carry -timeout=45m, matching the race legs so the file has one number, with comments saying it is a hang-catcher rather than a performance budget and that job wall-clock is the signal for "the suite got slow". Measured at |
||
|
|
212d59e7c6 |
fix(cli,server): make agent attribution actually happen (BUG-2542) (#1088)
* fix(cli,server): make agent attribution actually happen (BUG-2542)
Agent CLI writes were recorded as the human whose credentials they used.
Three independent defects, each verified by reading the path AND by
probing a live instance — the item deliberately held the mechanism open,
so none of this is inherited.
1. THE HEADER WAS NEVER SENT. actorFromRequest sets actor="agent" on one
signal: the X-Pad-Agent header. The only code that sets it took the
value from `agent_name` in .pad.toml and nowhere else — no
environment detection, no session detection. This repo's .pad.toml
has only `workspace`, so the header has never been sent from here and
every agent write has looked human. ResolveAgentName now resolves
.pad.toml → $PAD_AGENT → detected runtime.
2. ITEM CREATE DISCARDED THE ACTOR. createItemChecked called
actorFromRequest and kept only the source (`_, src :=`), never
setting input.CreatedBy, so store.CreateItem fell through to its
"user" default — even for an agent that DID send the header.
Comments have always stamped it correctly; item creation silently did
not, which made the skill's own contract false on its own terms.
3. SINGLE-ITEM PATCH NEVER STAMPED LastModifiedBy. Bulk ops do
(handlers_items_bulk.go); the single-item path did not, so an item
edited only by agents read as human-edited.
Only entries VERIFIED against a live session belong in the runtime
detection table, so it has exactly one: Claude Code exports CLAUDECODE=1
to child processes, confirmed by reading a pad subprocess's environment
inside one. Guessing at Cursor/Windsurf/Aider variable names would put
unverified claims in a shipped binary and misattribute silently when
wrong; those set $PAD_AGENT until someone confirms a signature.
WHAT THIS DOES NOT DO, stated in the code and the skill rather than left
for someone to assume: the header is client-supplied and self-declared.
An agent that omits it is indistinguishable from the human it borrows
credentials from, and a human running `! pad ...` inside an agent's
terminal inherits that environment and is attributed to the agent. This
makes the trail HONEST, not VERIFIED — it is not a basis for
machine-verifiable human-approval provenance, which needs a channel the
agent cannot author at all. The incident behind this item is exactly
that distinction: an agent's relay of a human's words was recorded
indistinguishably from the human typing them.
Contract corrected in both skill copies, since the item's first question
was which of contract and behavior was wrong. It was the contract: it
promised automatic agent attribution that only ever applied to
workspaces that had opted in.
Tests, each mutation-tested against its own defect reverted alone:
- TestResolveAgentName — precedence plus the negative that makes it mean
something: a plain human shell must still resolve to "". Fails 2/5
reverted.
- TestItemAttribution_AgentVsHuman — agent and human legs for create,
update and the create-stamp-survives-edit invariant. Fails on the
create stamp reverted; fails 2/2 on the update stamp reverted.
The update leg deliberately uses the OTHER writer: insertItemTx seeds
last_modified_by FROM created_by, so a same-writer edit passes whether
or not the PATCH stamps anything — the first version of this test did
exactly that and passed its own counterfactual. Caught only because
each fix was reverted separately.
- TestItemAttribution_ExplicitBodyValueWins — an explicit body value
still beats the header.
End-to-end on a live instance through the real CLI, no .pad.toml opt-in:
agent session → created_by/last_modified_by/comment all `agent`; same
binary with CLAUDECODE stripped → all `user`.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* fix(server): artifact import wrote a UUID into created_by (BUG-2542)
Found by Codex while reviewing the attribution fix. handleImportArtifact
set `input.CreatedBy = u.ID`, which is the wrong DOMAIN for the field
rather than merely the wrong value: created_by holds the role — "user"
or "agent" — and consumers compare it against those literals
(CommentThread.svelte, TimelineVersionCard.svelte). An imported item
matched neither and rendered as neither.
It also would have defeated the fix in the parent commit at this path: a
non-empty CreatedBy suppresses the actor stamp, so imports would have
kept a UUID while every other create path started recording the actor.
The line contradicted the comment directly above it, which said Source
was being left blank precisely so createItemChecked could stamp it "like
every other create path". Now both fields are left blank and stamped
together.
The user's identity has its own home — the items.created_by_user_id
column — which no create path currently populates. That is a separate
gap and is not widened into this change.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* fix: close the remaining attribution bypasses Codex found (BUG-2542)
Review found no P1s and three P2 families beyond the artifact-import bug
already fixed in
|
||
|
|
b87b3028e7 |
chore(nix): bump package version to 0.13.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01CXHLbTC1AiwSC87xwThRGTv0.13.0 |
||
|
|
f241a6298e |
docs(skill): port the applicable plugin-review corrections into the embed source (TASK-2537) (#1087)
* docs(skill): port the applicable plugin-review corrections into the embed source
skills/pad/SKILL.md is the //go:embed source that `pad agent install`
writes into user projects; it had drifted from corrections made to the
plugin's copy during TASK-2534's review rounds. Selective port — the two
files diverge by design (surface-agnostic vs Claude-Code-only), so none
of the plugin's Claude-Code-specific material comes across.
Each ported item re-verified against the code, since line numbers and
wording differ between the copies:
- Ideation example passed `--content "..." --stdin` together.
cmd_item.go:141 shows --stdin OVERWRITES the --content value and then
blocks on io.ReadAll with nothing piped, so the example as written
hangs. Dropped --stdin.
- Retro's plan-status flip carried no --comment, contradicting Key
Principle 2 four lines below it.
- Key Principle 2 named `blocked` as a task status. templates.go:157 has
open / in-progress / done / cancelled — no `blocked`.
- The role-board pointers claimed `pad server open` → /{workspace}/roles.
cmd_server.go:999 appends only the workspace slug; there is no
sub-path. Replaced with navigate-to-the-Roles-page wording.
Two more from the same review rounds that apply here and were not listed
on the task, found by diffing the copies:
- The convention/playbook BODY loads used `--format json` without
`--full`. Since the v0.9 summary shape, `pad item list` returns
cli.ToItemSummaries, which has no `content` field at all — so a skill
told to "follow ALL returned conventions" was reading titles. Added
--full to the seven trigger-load examples and to the retro's task
load, where the bodies are the point.
- `open "$IMG"` is macOS-only, in a file installed on every platform.
Not ported, and not a gap in this file: the whoami-gated
`pad workspace init` routing. That correction guards a blind self-heal
in the plugin's bootstrap-failure branch, and the embed source has no
such branch — its only `pad workspace init` mention is a neutral
see-also. The absence of ANY bootstrap-failure guidance here may be
worth its own item; inventing that section is outside a port.
Verified through the real embed path, not by reading the diff: rebuilt
and ran `pad agent install` for both targets (claude → .claude/skills,
codex/cursor/windsurf/opencode → .agents/skills) into scratch dirs, and
confirmed the installed artifacts carry every correction and none of the
superseded strings. go test ./... green.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* docs(skill): --full on the eighth body-load; file the bootstrap-failure gap
Codex found one miss and one scope question.
The miss: the `convention_index` bullet's own body-load example lacked
both `--format json` and `--full`, so the call that the bullet exists to
describe — pull the triggered convention BODIES this index only names —
came back in the summary shape with no `content`. It is the eighth such
call; I corrected seven and missed the one inside prose rather than in a
code block. Added, with a clause saying why, since this is the bullet a
reader consults specifically to learn how to fetch bodies.
The scope question: the embed source has no bootstrap-failure branch at
all, so an unlinked workspace routes into onboarding that cannot run,
and a naive `pad workspace init` self-heal can hang the tool call
indefinitely. Codex is right that it is a real gap and right that it is
surface-agnostic. It is not a port, though — the plugin's correction
guards a self-heal this file does not contain, so there is nothing here
to correct, only something to write. Filed as BUG-2541 with the failure
modes, the verified hang, and the de-Claude-ing needed, rather than
widened into this PR.
Re-verified through `pad agent install`: 9 body-load calls now carry
--full in the installed artifact.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* docs(skill): warn off the blind `pad workspace init` self-heal
Codex held on the bootstrap-failure gap after I deferred it to BUG-2541,
and the objection is fair on one point: deferring the whole branch left
the shipped artifact one step from the hazard, since the documented
fallback ("use the individual CLI calls") needs the same workspace link
that just failed, so a reader following this file has nowhere to go and
`pad workspace init` is the obvious next reach.
So the hazard gets addressed here and the structure stays in BUG-2541.
Four sentences, surface-agnostic: bootstrap failing usually means setup;
the individual-call fallback won't help; do NOT run `pad workspace init`
blind, because on a configured-but-unauthenticated machine it hangs
indefinitely on a browser-setup URL with no timeout and no
non-interactive fallback — wedging the tool call rather than failing it;
gate on `pad auth whoami` and hand back to the user otherwise.
That IS what TASK-2537's finding-1 asked for — "the same whoami-gated
treatment" for this file's onboarding-adjacent text. I first read its
parenthetical ("if any references pad workspace init as a blind
self-heal") as gating the whole item, and since this file has no such
reference, as nothing to do. The intent was to keep the file from
leading an agent into the hang, which it could.
What stays in BUG-2541: the two stderr signatures as named cases, the
Onboarding routing entry's missing precondition, and re-verifying the
hang rather than inheriting the claim. Noted there.
Verified through `pad agent install` again, not the diff.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* docs(skill): narrow the workspace-init warning to what the code does
I wrote that `pad workspace init` "hangs indefinitely ... with no
timeout", inherited from TASK-2534's write-up. Codex flagged it and it
is wrong. What the code does:
- first-admin setup polls under an explicit 20-minute cap
(internal/cli/bootstrap.go::bootstrapPollTimeout);
- the configured-but-unauthenticated branch polls a CLI auth session
every 2s with no wall-clock timeout of its own, but exits on the
server's `expired` status, and that session's TTL is 5 minutes (20 for
the setup handoff) — internal/store/cli_auth_sessions.go.
Bounded, then. The hazard is still real and still worth the warning — it
blocks an agent's tool call for minutes on a flow only a human at a
browser can finish — but the text now says that instead of claiming a
permanent wedge. Also qualified the whoami claim: it returns immediately
in NON-INTERACTIVE use; getConfiguredConfig can prompt on an interactive
TTY (cmd/pad/configure.go:80).
Worth naming because it is the same failure I had just written into
BUG-2541 as work to do — "re-verify the hang claim rather than
inheriting it" — and then committed the inherited claim as fact in the
same breath. An explanation I have not checked is a claim, not a hedge,
and putting it in a file that ships to users makes it everyone's. The
correction is on BUG-2541 too, so that item's body isn't left asserting
it.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
|