diff --git a/.empirical/capabilities/project-view-preferences/spec.md b/.empirical/capabilities/project-view-preferences/spec.md index b2adbaa..b347620 100644 --- a/.empirical/capabilities/project-view-preferences/spec.md +++ b/.empirical/capabilities/project-view-preferences/spec.md @@ -9,23 +9,29 @@ with server-side project configuration or leaking values across projects. ### Requirement: Project-scoped browse memory -Taskboard SHALL store one versioned, validated, device-local browse preference -record per BB project and selected provider, plus an independent Across projects -record. The full board and right panel SHALL share the same project record, -including the active search query. - -#### Scenario: Return from issue detail - -- **GIVEN** the user entered a search query for project A -- **WHEN** they open an issue and return to either Taskboard surface -- **THEN** project A's query and filtered results remain active -- **AND** project B and Across projects retain their independent queries - -#### Scenario: Parse a legacy record - -- **WHEN** a valid version-1 record created before search persistence has no - query field -- **THEN** Taskboard preserves its saved filters and supplies an empty query +Taskboard SHALL retain one versioned, validated, device-local browse preference +record per BB project/provider plus the independent Across projects scope. It +SHALL additionally store explicitly named preset snapshots per BB project in the +plugin database. Presets SHALL reuse the complete released browse preference +shape and SHALL apply only on explicit user/CLI action through the ordinary +preference update path. + +#### Scenario: Apply a named preset + +- **GIVEN** project A has a preset containing query, filters, List/Kanban view, + and collapse overrides +- **WHEN** the user applies it from project A's filter bar +- **THEN** every preset field replaces project A's current browse state +- **AND** the full board and right panel immediately share the result +- **AND** project B and Across projects remain unchanged + +#### Scenario: Return later + +- **GIVEN** a preset was applied to project A +- **WHEN** the user reloads or returns from issue detail +- **THEN** the existing device-local project preference record restores the + applied state +- **AND** no separate server filter-state writer races with it ### Requirement: Separate create-assignee memory @@ -56,3 +62,37 @@ query with its derived selections. - **WHEN** the user clears filters while viewing project A - **THEN** project A's query and facets reset - **AND** project B and Across projects retain their own saved selections + +### Requirement: Safe project preset management + +Preset names and state SHALL be bounded and strictly validated. Names SHALL be +case-insensitively unique using locale-independent normalization; ordering SHALL +be deterministic and reorder writes SHALL require an exact permutation of the +visible project preset IDs. Corrupt rows SHALL be omitted individually, and +deleting an absent preset SHALL be idempotent. + +#### Scenario: Duplicate name + +- **WHEN** a user saves a preset whose normalized name already exists in the + same project +- **THEN** the write is rejected without modifying either preset + +#### Scenario: Corrupt stored preset + +- **WHEN** one stored row fails strict state validation +- **THEN** valid presets still list and can be reordered against the parseable + subset + +### Requirement: Preset CLI parity + +The Taskboard CLI SHALL list, save, rename, and delete project presets and SHALL +accept `list --preset `. Explicit source/query flags SHALL override the +preset's source/query; remaining preset facets SHALL use the same pure filter +function as the UI. + +#### Scenario: List with explicit override + +- **GIVEN** preset `My work` stores query `bug` and source `linear` +- **WHEN** the user runs `bb taskboard list --preset "My work" --query urgent` +- **THEN** `urgent` overrides the preset query +- **AND** the preset's remaining facets still narrow results diff --git a/.empirical/capabilities/usage-tracker-compact-display/spec.md b/.empirical/capabilities/usage-tracker-compact-display/spec.md new file mode 100644 index 0000000..1ae934a --- /dev/null +++ b/.empirical/capabilities/usage-tracker-compact-display/spec.md @@ -0,0 +1,33 @@ +# Usage Tracker Compact Display Specification + +## Purpose + +Define which provider usage window appears in the compact sidebar reading. + +## Requirements + +### Requirement: Configurable compact limit + +Usage Tracker SHALL let the user select Weekly or Five-hour as the compact +sidebar limit. Weekly SHALL be the default for new, legacy, missing, and unknown +preference values. The compact percentage and progress bar SHALL update when the +setting changes without hiding either window from expanded details. + +#### Scenario: Preferred window is available + +- **GIVEN** Weekly is selected and both windows are reported +- **WHEN** the compact provider reading renders +- **THEN** its percentage and progress bar use the weekly window +- **AND** expanded details still show both weekly and five-hour windows + +#### Scenario: Preferred window is missing + +- **GIVEN** Five-hour is selected but only the weekly window is reported +- **WHEN** the compact provider reading renders +- **THEN** it falls back to the weekly window rather than showing no limit + +#### Scenario: Legacy value is loaded + +- **WHEN** stored or RPC preference data omits or provides an unknown compact + limit +- **THEN** Usage Tracker normalizes the effective value to Weekly diff --git a/.empirical/context/architecture.md b/.empirical/context/architecture.md index c316e87..8bbc6d5 100644 --- a/.empirical/context/architecture.md +++ b/.empirical/context/architecture.md @@ -15,8 +15,12 @@ - `plugins/taskboard/contract.ts` and companion schemas define the strict JSON wire model. `store.ts` owns append-only SQLite migrations and cached work. `sources/` contains the GitHub, Linear, and Jira adapters behind one interface. +- `browse-preferences.ts` owns the observable device-local current view; + `filter-presets.ts` validates complete named snapshots while `store.ts` owns + their project-scoped SQLite CRUD/order and `server.ts` exposes RPC/CLI plus + realtime invalidation. - `plugins/usage-tracker` is independent and owns its own server, app, provider - usage model, tests, and assets. + usage model, compact-limit preference, tests, and assets. - `plugins/machine-monitor/server.ts` owns fleet refresh, last-good snapshots, thresholds, and host-targeted process orchestration. `host.ts` collects a strict privacy-bounded telemetry/process projection on an enrolled machine; @@ -43,8 +47,11 @@ request, caches the returned item, and inserts a Taskboard mention into the BB composer. The external provider is never written before confirmation. 6. A background service refreshes configured projects; the external tracker - remains authoritative when cache and live state differ. -7. Host Monitor asks BB for enrolled hosts, samples only connected targets via + remains authoritative when cache and live state differ. +7. Named presets never auto-apply. UI application provider-checks a preset, + atomically replaces the current project `BrowsePreferences`, and lets the + existing observable store synchronize full/right-panel surfaces. +8. Host Monitor asks BB for enrolled hosts, samples only connected targets via the authenticated host-worker boundary, validates every response, and keeps the last good reading when a target becomes stale, fails, or disconnects. Process lists are fetched only for the explicitly opened host and stop diff --git a/.empirical/context/commands.md b/.empirical/context/commands.md index feb92f9..ba62b8f 100644 --- a/.empirical/context/commands.md +++ b/.empirical/context/commands.md @@ -36,6 +36,10 @@ Commands below are verified from workspace and plugin manifests. - `npm run types:refresh --workspace bb-plugin-taskboard` followed by `npm install` — deliberate SDK declaration pin refresh when the minimum BB release changes. +- `bb taskboard presets list|save|rename|delete` — manage named presets for the + current or explicitly selected BB project. +- `bb taskboard list --preset ` — apply a named preset to CLI listing; + explicit `--source` and `--query` flags take precedence. ## Verification evidence diff --git a/.empirical/context/index.md b/.empirical/context/index.md index f55eeb8..30c49d2 100644 --- a/.empirical/context/index.md +++ b/.empirical/context/index.md @@ -3,9 +3,9 @@ Generated from bounded repository metadata. -- Repository: bb-plugins -- Source fingerprint: sha256:c10945040eafc31d2860d5258d0c9a0e6843abf05e6e91927f384331f3be7fec -- Included files: 145 +- Repository: bb-plugins-taskboard-dev +- Source fingerprint: sha256:227dd1f413a7fbde5332a3aa37aada62b4bcfe1dbc0de2e8dba7c58781784a7a +- Included files: 148 - Roots: .agents, .bb, .claude, .codex, .cursor, .gemini, ., .github, .windsurf, demos, plugins - Manifests: package.json, plugins/machine-monitor/package.json, plugins/taskboard/package.json, plugins/usage-tracker/package.json - Primary docs: README.md, plugins/machine-monitor/README.md, plugins/taskboard/README.md, plugins/usage-tracker/README.md diff --git a/.empirical/context/manifest.json b/.empirical/context/manifest.json index 91dcf09..2b09f55 100644 --- a/.empirical/context/manifest.json +++ b/.empirical/context/manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generator": "empirical-0.28.0", - "sourceDigest": "sha256:c10945040eafc31d2860d5258d0c9a0e6843abf05e6e91927f384331f3be7fec", + "sourceDigest": "sha256:227dd1f413a7fbde5332a3aa37aada62b4bcfe1dbc0de2e8dba7c58781784a7a", "files": [ { "path": ".agents/skills/empirical/SKILL.md", @@ -80,8 +80,8 @@ }, { "path": "README.md", - "size": 6741, - "digest": "sha256:9a39f8e17a49ef0991afab0d08445ed81ba801042be1d1ef19525d32dcbb17a4" + "size": 7278, + "digest": "sha256:e3ab4bf3b1ce0ed5ab98c25726f6d2da2e908bda4144cb2ac075ee63515ae4ec" }, { "path": "THIRD_PARTY_NOTICES.md", @@ -355,8 +355,8 @@ }, { "path": "plugins/taskboard/README.md", - "size": 10657, - "digest": "sha256:e49d179da5fc15f6b7ba2bf7c654a4ee4d0f0b2a042311258c8d2e8545f8db47" + "size": 11791, + "digest": "sha256:47693d5c57e8fa68ae90d8808a4876c2e14d4e2477b0e050baec1cb3b0509e3b" }, { "path": "plugins/taskboard/THIRD_PARTY_NOTICES.md", @@ -370,8 +370,8 @@ }, { "path": "plugins/taskboard/app.tsx", - "size": 216411, - "digest": "sha256:d9ff669f5363b241fc38f6cfdb374f2779fdd3048b4b774fd2528ab0b3a368f2" + "size": 239232, + "digest": "sha256:ac8a009303fcfbbb10d2379e0198c540848c18143d578115c82ff29bae2f5b3c" }, { "path": "plugins/taskboard/assets/icon.svg", @@ -495,14 +495,19 @@ }, { "path": "plugins/taskboard/contract.ts", - "size": 16418, - "digest": "sha256:58ab4008601b064136962ebcf4573f6f19b60b7c44ce97bb19f8aed1a727e38b" + "size": 18398, + "digest": "sha256:20a598ce7cc92280f7f266e096c7dc50ecc948a48adbc202d4c57c77b3d09b7c" }, { "path": "plugins/taskboard/create-issue.ts", "size": 1553, "digest": "sha256:a8890400be739b897eebee623b5b67a37cb7075b091193ce6c6eef026d1c5018" }, + { + "path": "plugins/taskboard/filter-presets.ts", + "size": 5891, + "digest": "sha256:6186b8b62c06492659d4f60906bd86080079e80fa71ed7a3a06caacd0ea011d7" + }, { "path": "plugins/taskboard/hooks/useBrowserDimmingModal.ts", "size": 812, @@ -540,8 +545,8 @@ }, { "path": "plugins/taskboard/server.ts", - "size": 81420, - "digest": "sha256:489f0d029402698304d23467e1b81a1630f9afcd775347ad584132bce736cfb5" + "size": 92555, + "digest": "sha256:a91d2dc82445d49b6df3498bbe28443477283b6b93cc277ba232c135d1fb8752" }, { "path": "plugins/taskboard/sources/github.ts", @@ -570,13 +575,13 @@ }, { "path": "plugins/taskboard/store.ts", - "size": 22822, - "digest": "sha256:b399f0ccaa917f83c71146fb0d2e0a270c1d4d6d35405d317b9d97e4f4f18468" + "size": 32487, + "digest": "sha256:f18691aa11e7a8151c3ffdad51304cff24e364764f5a9a502f9a381343f3d469" }, { "path": "plugins/taskboard/test/app-ui.test.ts", - "size": 6827, - "digest": "sha256:b30785467f38c82749a7e7efab5fa67c1d43985da086517c2a96994bf95c9ce0" + "size": 7593, + "digest": "sha256:cdb010128eb35d32caf64180f88d4012c2b871c3cef3d0fb778210245e1e556f" }, { "path": "plugins/taskboard/test/browse-preferences.test.ts", @@ -600,8 +605,18 @@ }, { "path": "plugins/taskboard/test/distribution.test.ts", - "size": 5738, - "digest": "sha256:f38909df86e53a0851c06b3f14842050ea0b0f9c664151fda62bf04d96bfd3d7" + "size": 6759, + "digest": "sha256:1642e47bc3932a2356ab7962dc4ddcd3f4da9b5dcb2bc4eab0c1f1c2c8428214" + }, + { + "path": "plugins/taskboard/test/filter-presets-backend.test.ts", + "size": 7411, + "digest": "sha256:eb6fef2a388eb4282ae145c2d084c3d5d4139503886b390e9d61562ec0f64aa6" + }, + { + "path": "plugins/taskboard/test/filter-presets.test.ts", + "size": 4247, + "digest": "sha256:dd0671288f9c6b339896ad637bd4dfb569a8ceb3ba39191ea81c8c54ad255fda" }, { "path": "plugins/taskboard/test/issue-draft.test.ts", @@ -630,8 +645,8 @@ }, { "path": "plugins/usage-tracker/CHANGELOG.md", - "size": 979, - "digest": "sha256:7482a3514bb1ff745b689c622f7c9d928fde1ced493dfd8b1bf049dcb0918bc6" + "size": 1230, + "digest": "sha256:3a63a16cd6f7a26c2e9c8acdab3be1b2325262212362938d76abd296aad64882" }, { "path": "plugins/usage-tracker/LICENSE", @@ -640,8 +655,8 @@ }, { "path": "plugins/usage-tracker/README.md", - "size": 4365, - "digest": "sha256:3edf3d931c5d3c40d4a712ad0797ae8d960cf51a7e0f32af4524e86725b429e2" + "size": 4703, + "digest": "sha256:08150c1e0fcc972e2596c055ab93f1080f4247e90e9f732f4b3c2b93594019e4" }, { "path": "plugins/usage-tracker/THIRD_PARTY_NOTICES.md", @@ -680,8 +695,8 @@ }, { "path": "plugins/usage-tracker/lib/preferences.ts", - "size": 512, - "digest": "sha256:bc87248e00fbc0144ec54e6a3ffdeff2726a23e6d96bd4aa33c1e6f778f739ee" + "size": 915, + "digest": "sha256:54037939799ababd759fd74fc5fb595686052a8ba33e5b5bbb8cf6f9981e4dde" }, { "path": "plugins/usage-tracker/lib/provider-marks.ts", @@ -690,13 +705,13 @@ }, { "path": "plugins/usage-tracker/lib/sidebar-strip.ts", - "size": 17252, - "digest": "sha256:5111901332f34509008ca79960a962d04a15f8f478f56bf2012dc6775bc26ce8" + "size": 18240, + "digest": "sha256:0b365b5658e87efd43c81b7384b256f9d17838b041ec6a9887497eee11acc4ed" }, { "path": "plugins/usage-tracker/lib/sidebar-usage.ts", - "size": 2271, - "digest": "sha256:46a12cea97d95fddf06c38539db7439d356bb66db0f2716243f2771fd2b6ca7e" + "size": 2622, + "digest": "sha256:49435c8b69a228e71b9095bf27fca198d537e59d076289012b66d08f9dd30998" }, { "path": "plugins/usage-tracker/lib/usage.ts", @@ -710,13 +725,13 @@ }, { "path": "plugins/usage-tracker/server.ts", - "size": 2382, - "digest": "sha256:9a387ac8e3e6c804944c55d66a60d1d0b0f0edee71e28f5b5461d0daa02d5502" + "size": 2832, + "digest": "sha256:796544a8d3f27cb632edf7a29af4170a8bb5422d0008129234cf233f5af9839e" }, { "path": "plugins/usage-tracker/test/usage.test.ts", - "size": 9451, - "digest": "sha256:b65badb72e9c4e7700f0ef20dc2212451c0a09212aa94059ceebcae54e53bf0f" + "size": 10762, + "digest": "sha256:d847f1d6659ae78c7b80fc23748c054e5667b4a0a79bf067f213f5aa36b107e1" }, { "path": "plugins/usage-tracker/tsconfig.json", @@ -835,6 +850,7 @@ "plugins/taskboard/components/ui/tooltip.tsx", "plugins/taskboard/contract.ts", "plugins/taskboard/create-issue.ts", + "plugins/taskboard/filter-presets.ts", "plugins/taskboard/hooks/useBrowserDimmingModal.ts", "plugins/taskboard/issue-draft.ts", "plugins/taskboard/lib/portal-scope.ts", @@ -855,6 +871,8 @@ "plugins/taskboard/test/contract.test.ts", "plugins/taskboard/test/create-metadata.test.ts", "plugins/taskboard/test/distribution.test.ts", + "plugins/taskboard/test/filter-presets-backend.test.ts", + "plugins/taskboard/test/filter-presets.test.ts", "plugins/taskboard/test/issue-draft.test.ts", "plugins/taskboard/test/project-selection.test.ts", "plugins/taskboard/test/source-types.test.ts", @@ -881,8 +899,8 @@ "plugins/usage-tracker/tsconfig.json", "plugins/usage-tracker/types/css-modules.d.ts" ], - "sourceDigest": "sha256:c10945040eafc31d2860d5258d0c9a0e6843abf05e6e91927f384331f3be7fec", - "digest": "sha256:1e99625f48dacde8df49df1f670b46b700a29450a8f9f6c6cd7c8fabe2813947", + "sourceDigest": "sha256:227dd1f413a7fbde5332a3aa37aada62b4bcfe1dbc0de2e8dba7c58781784a7a", + "digest": "sha256:840b9aefb7ee18b9652bae8a4d2f01a08e48da5cd2362a1dc127986940919b24", "freshness": "fresh" }, { @@ -901,8 +919,8 @@ "plugins/usage-tracker/README.md", "plugins/usage-tracker/package.json" ], - "sourceDigest": "sha256:03f70d2e214ed63f2a6d8a63e1f1b0b9a1fc6904eb22f96ad1e07cf489b04839", - "digest": "sha256:55ebbb86949c1b08092a2a7ea4ea85e38b59626730da7dd390070fb258f51505", + "sourceDigest": "sha256:ffc3ddfdafcdc1db1460b8992070bf6d66e4727b6ee19fcf8cb8d4b24f36f854", + "digest": "sha256:d16b14f5a79e3f394637b0cb6e88f189b1dfa923e89706ce6ce29087bb895090", "freshness": "fresh" }, { @@ -916,7 +934,7 @@ "plugins/usage-tracker/package.json" ], "sourceDigest": "sha256:3c44cc059bd5b8e6f6b979c6908e07b2c148240560f09848eb649c867c035150", - "digest": "sha256:8ba00b1dd58e5707912e067f406ee3be3ba73cd7f5cd96ebd93758b4a452e483", + "digest": "sha256:7648941706a421f42111c182cb29dde9c926cd9b921f9675aaa27a847f18482d", "freshness": "fresh" }, { @@ -931,7 +949,7 @@ "plugins/usage-tracker/package.json" ], "sourceDigest": "sha256:b6055cc3f0fdf17329b3e2757d8f4e7cf945d56d032ead9c96d0cee7610b40f1", - "digest": "sha256:d56906b359fd58554bc579840482e23ec5b9c6c890a6dd099345dada1c112ccc", + "digest": "sha256:d339208cb167545e3d012c812543f42fc98d0a47b869583832873d97b8e09cca", "freshness": "fresh" }, { @@ -950,5 +968,5 @@ } ], "truncated": false, - "digest": "sha256:a88a8175207e36c923521298fe7462db83f48827f1027151e06725ddc6ff376f" + "digest": "sha256:ecc065936c0960d1c12d67cd1b9d6a49e0deb4c05d42e9214a29597fd04911eb" } diff --git a/.empirical/context/overview.md b/.empirical/context/overview.md index 78038e8..5101446 100644 --- a/.empirical/context/overview.md +++ b/.empirical/context/overview.md @@ -5,9 +5,9 @@ - This workspace contains focused plugins for BB, the agent IDE. - Taskboard gives each BB project one GitHub, Linear, or Jira-backed List/Kanban board with cached browsing, live detail, status changes, issue creation, - mentions, CLI access, and agent handoff. + remembered and named project views, mentions, CLI access, and agent handoff. - Usage Tracker places Codex and Claude Code quota windows in BB's sidebar - footer. + footer and lets the user choose the weekly or five-hour compact reading. - Host Monitor presents live CPU, RAM, disk, network, load, uptime, connection, and sample-health data for every enrolled BB host, with guarded on-demand process inspection and termination. @@ -22,6 +22,9 @@ - Taskboard selects exactly one external tracker per BB project. Provider APIs remain authoritative; Taskboard stores project configuration, credentials, cached summaries, sync state, and board preferences. +- Taskboard's current browse state stays versioned and device-local; named + project presets store validated snapshots in the plugin database and apply + explicitly through that same browse store. - Taskboard, Usage Tracker, and Host Monitor are private/non-publishable workspaces. They release through immutable plugin-specific Git tags plus the BB Community marketplace. diff --git a/.empirical/policy.json b/.empirical/policy.json index 189063c..114ccf5 100644 --- a/.empirical/policy.json +++ b/.empirical/policy.json @@ -34,9 +34,10 @@ "AC-UI-3", "AC-UI-4", "AC-UI-5", - "AC-6", - "AC-7", - "AC-8" + "AC-6", + "AC-7", + "AC-8", + "AC-9" ] } ] diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/authorization.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/authorization.json new file mode 100644 index 0000000..f472c15 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/authorization.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "mode": "yolo", + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "requestDigest": "sha256:98f35754a8b11e84752feeb0715ef4e91efe4bdf6dc61d867feb04a8d1b06fcb", + "ceiling": "integrated", + "targetBranch": "main", + "allowExternalAgent": false, + "createdAt": "2026-08-27T01:58:15.408Z", + "expiresAt": null, + "digest": "sha256:8e131cdb10ceb4f25b9b720581e879a03c6bbaf5b3ae32c40a47ade191d29a19" +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/code-review.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/code-review.md new file mode 100644 index 0000000..b362809 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/code-review.md @@ -0,0 +1,163 @@ +# Independent Code Review + +- Reviewer: code-review +- Verdict: advisory +- Source base: `fc4a1b9d66f8b9707874663b08b754edcf1a1c64` +- Reviewed candidate: `fb74be3f9f81f2d9f00de073ef79bc395485b7ce` + on `integrate-community-prs` +- Scope: AC-1 through AC-9, decisions D-001 through D-005, original PR heads, + merge resolution, current-main/Host Monitor reconciliation, product code, + tests, manifests, documentation, context, and contributor attribution + +## Criterion dispositions + +### AC-1 — Pass + +Usage Tracker defines Weekly and Five-hour as the only Compact limit choices, +normalizes missing and unknown values to Weekly at RPC and browser-cache +boundaries, and observes settings without reloading the plugin. Compact text +and rail geometry use the configured current window, then the current +alternative, then last-known data. The merged last-known snapshot remains +separate so expanded details continue to show both windows. Accessible labels +distinguish configured, actual, alternative, and last-known states. The final +17-test Usage Tracker suite covers normalization, both selections, both +fallback directions, fresh-versus-stale ordering, expanded-window retention, +and refresh/error behavior. + +### AC-2 — Pass + +The released `BrowsePreferences` architecture remains the only automatic +current-view owner. It is still versioned, strict, device-local, scoped by BB +project/provider plus Across projects, and shared by full and constrained +surfaces through one observable store. Query, view, every facet, provider, and +collapse overrides remain intact. Merge `bbdb3b8` uses the whole-tree `ours` +strategy and its tree equals its first parent exactly; no +`project_filter_state`, `BoardFilterState`, `filter-state.ts`, filter-state RPC, +or second server writer survives. Existing stale-request revision guards and +provider reconciliation remain in place. + +### AC-3 — Pass + +Project boards expose one bounded Presets menu in full and constrained layouts. +Save captures the complete current `BrowsePreferences`; Apply validates project +and provider identity and replaces the active project snapshot through +`browsePreferenceStore.set`, immediately notifying both surfaces. The applied +name is announced. Manage supports explicit rename, reorder, and delete with +serialized mutations, authoritative response lists, stale-request/project +guards, draft preservation, and logical focus restoration. Across projects has +no preset surface, and no default, dirty, active, or automatic preset behavior +was introduced. + +### AC-4 — Pass + +Preset persistence is project-scoped and parameterized. The strict state schema +prevalidates only known bounded fields without allocating attacker-sized +worklists, rejects accessors/inherited containers and controls, preserves the +complete version-1 browse shape, and enforces provider/source consistency. +Writes enforce per-state and aggregate UTF-8 byte ceilings transactionally; +RPC/CLI output is kept under the SDK ceiling without duplicating the saved +state. The table has an explicit non-null primary key, bounded columns and +positions, and project/name uniqueness. + +Reads are capped, invalid rows are omitted individually, and raw IDs, project +IDs, names, and normalized names must round-trip canonically. Names use +locale-independent NFKC plus lowercasing. Creation enforces the 50-row limit; +ordering is deterministic; reorder accepts only an exact permutation of the +visible parseable IDs and is atomic; deletion of an absent ID is a no-op. +Provider validation and saving share the project mutation queue, while rename +is state-preserving only. Adversarial container, UTF-8 maximum, aggregate, +overflow, corrupt-row, duplicate, isolation, and permutation cases pass. + +### AC-5 — Pass + +The CLI lists, saves, renames, and deletes project presets and accepts +`list --preset`. Names resolve case-insensitively. Explicit `--source` and +`--query` values take precedence, including an explicit empty query; remaining +facets use `filterWorkItemsByAttributes` and honor the project's enabled board +facets like the UI. Results remain bounded to 200 items. Non-cached listing +revalidates the preset provider after synchronization, preventing an +old-provider preset from filtering items after a concurrent connector switch. +JSON preset output enforces BB's one-megabyte CLI limit. + +### AC-6 — Pass + +Compared directly with current `origin/main`, root/plugin manifests, +`package-lock.json`, `.bb/plugins.json`, CI, Taskboard `0.3.1`, Usage Tracker +`0.1.2`, Git-only/private distribution fields, and the complete Host Monitor +tree are unchanged. The 109-test Taskboard regression suite covers browsing, +query, filters, List/Kanban, collapse state, remembered assignee, creation, +provider safety, credentials, status movement, distribution, and the new preset +paths. Usage Tracker retains provider visibility, refresh, cache, and error +behavior while adding the compact selection. `git diff --check` passes. + +### AC-7 — Pass + +The root README describes both capabilities and contains a Contributors section +with exact links for Stephen Dolan (`https://github.com/stephendolan`) and +Andrii Los (`https://github.com/RIP21`). The Usage Tracker README/changelog +credit Compact limit; the Taskboard README credits the project-view work and +named presets and documents UI, Manage, CLI CRUD, `list --preset`, explicit +precedence, project scope, and no automatic application. Refreshed Empirical +context describes the same ownership and commands while retaining Host Monitor. + +### AC-8 — Pass for history; remote PR state is a delivery gate + +Exact heads `201c44a5f825c724fcfdfa6876437ceb2730814b` (#10), +`445e5ac4f3943a8eb4821f27a808715f46e57450` (#11), and +`91037db6add4edd08c313025cb8a1e51896458ad` (#12) are ancestors of the candidate. +Stephen Dolan remains author of #10; Andrii Los remains author of all six #11 +and seven unique #12 commits. #11 is intentionally represented by the exact +whole-tree `ours` merge; #12 remains a merge parent and its useful seven-commit +delta is adapted in the owner resolution commits. Current main, including Host +Monitor, is also an ancestor. Remote #10 is merged; #11 and #12 remain open +until the reviewed integration is merged, after which their exact heads must be +confirmed as main ancestors or closed with the specified supersession link. + +### AC-9 — Pass for code review; final workflow gates remain + +Final independent reruns pass Taskboard TypeScript and 109/109 tests, Usage +Tracker 17/17 tests, ancestry checks, unchanged manifest/lock/Host Monitor +comparison, and `git diff --check`. Signed receipt +`executed-13777e8d218261389af72cdf` records the exact final product tree's root +`npm run check`: all three workspace checks, Taskboard build metadata, and +plugin builds pass. Live reload exercises, Empirical detached integration, +hosted CI, normal remote merge, and final PR ancestry/state checks remain +required before claiming delivered. Those are workflow/delivery gates, not +product-code defects. + +## Decision consistency + +- D-001 is preserved by normal merge parents for all exact contributor heads. +- D-002 is preserved by retaining PR #10 behavior and authorship without a + package or release change. +- D-003 is preserved by the exact tree-neutral #11 `ours` merge and guards + against every obsolete persistence artifact. +- D-004 is preserved by porting only the useful #12 preset work onto strict + `BrowsePreferences`, current UI/provider safety, bounded storage, RPC, and + CLI contracts. +- D-005 is preserved by linked root/plugin credit plus original reachable + authorship. + +No accepted decision requires supersession. + +## Finding CR-001: retained-refresh retry focus is not deterministic + +- Severity: low +- Category: keyboard accessibility +- Location: `plugins/taskboard/app.tsx` retained-refresh retry controls in + `FilterPresetMenu` and `FilterPresetsForm` +- Detail: the menu's retained-error retry is a plain nested button rather than + a registered `DropdownMenuItem`, so Radix arrow-key navigation does not + reliably include it. In the menu and Manage, starting retry clears the error + and removes the pressed control without explicitly choosing the next focus + target. +- Impact: authoritative rows and drafts remain intact, pointer retry works, and + automatic realtime/reconnect refresh remains available. This does not block + merge but can make keyboard retry/disorientation worse after a transient + background failure. +- Recommendation: use a `DropdownMenuItem` for menu retry and retain a disabled + `Refreshing...` control or move focus to the preset trigger/heading before + removing the Manage retry. + +No blocking, high, or medium correctness, security, persistence, concurrency, +scope, packaging, ancestry, contributor-credit, or regression finding remains. diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md new file mode 100644 index 0000000..c940589 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md @@ -0,0 +1,84 @@ +# Security Consultation + +- Specialist: security +- Verdict: advisory + +## Review boundary + +This review covers the final integration tree against current `origin/main`, +including the preserved PR #10, #11, and #12 ancestry; Taskboard preset schema, +SQLite, RPC, CLI, realtime, and UI paths; Usage Tracker compact-window +selection; current Git-only manifests; and the post-review hardening changes. + +## Findings + +No blocking or medium findings remain. The issues found during the review were +repaired and retested: + +- Preset state and reorder inputs now preflight attacker-controlled container + cardinality before element parsing. The complete released + `BrowsePreferences` envelope remains accepted: the measured maximum valid + state is 900,441 UTF-8 bytes, below the 910,000-byte row bound, while a + transactional 950,000-byte project aggregate bounds list and mutation work. +- SQLite uses parameterized, project-scoped transactions, a non-null primary + key, canonical raw identity and normalized-name checks, bounded reads, exact + reorder permutations, and per-row corrupt-data omission. Derived-name + normalization failures are contained rather than aborting the list. +- The save provider check and write share the project mutation queue. CLI + preset listing rechecks provider identity after synchronization and applies + only facets enabled by current board settings. CLI JSON has an explicit SDK + byte ceiling, and save RPC returns the large state only once by pairing a + bounded saved-preset summary with the authoritative list. +- UI project revisions and scope identity reject late cross-project reads and + mutation responses. Realtime, reconnect, and post-mutation refreshes retain + last-known presets and drafts on transient failure. React renders preset and + error text as text, while CLI output visibly escapes external control and + bidirectional characters. +- Usage Tracker keeps the fresh provider snapshot separate from merged + last-known details. Compact selection prefers the configured fresh window, + then the fresh alternative, and only then labeled last-known data; unknown + preferences still normalize to Weekly. +- Taskboard and Usage Tracker remain private Git-only workspaces. No dependency, + package version, workflow, registry credential, or npm publication path was + introduced. Host Monitor and current mainline context remain intact. + +Verification observed 109 passing Taskboard tests, 17 passing Usage Tracker +tests, both focused typechecks, the full root workspace check/build, a clean +diff check, and a conflict-free merge tree against current `origin/main`. + +The following low residual findings do not cross a security authorization or +project-isolation boundary. + +### Finding RR-01 — Deliberately corrupted local databases fail closed + +- Severity: low +- Category: local availability / corruption recovery +- Location: `plugins/taskboard/store.ts` preset reads and capacity checks +- Exposure: An actor already able to bypass SQLite constraints or alter the + plugin database can fill a project with invalid rows. Reads and parsing stay + bounded and valid rows remain isolated, but corrupt rows can consume the + finite row or aggregate-byte quota until the local database is repaired. +- Recommendation: Keep fail-closed bounds and require an owner-invoked repair command. + +### Finding RR-02 — Unicode lowercase equality is not full case folding + +- Severity: low +- Category: display-name ambiguity +- Location: `plugins/taskboard/filter-presets.ts` `normalizePresetName` +- Exposure: NFKC plus locale-independent lowercase blocks ordinary case and + compatibility duplicates, but Unicode multi-character case-fold equivalents + such as `Straße` and `STRASSE` can remain distinct. Names carry no authority, + and controls/bidirectional overrides are rejected, so this is a visual + ambiguity rather than a project-isolation bypass. +- Recommendation: Adopt pinned Unicode case folding if full equality becomes required. + +### Finding RR-03 — Authorship preservation depends on normal merge delivery + +- Severity: low +- Category: provenance integrity +- Location: Git integration history and delivery strategy +- Exposure: The exact Stephen Dolan and Andrii Los commits are ancestors of the + integration branch, and PR #11's incompatible tree is retained only as an + unchanged `ours` merge parent. Squashing or rebasing the final integration + would discard the ancestry that provides contributor provenance. +- Recommendation: Use a normal merge and never rewrite contributor commits. diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md new file mode 100644 index 0000000..d1c4068 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md @@ -0,0 +1,73 @@ +# Final UI/UX and Accessibility Review + +- Specialist: ui-ux +- Verdict: advisory +- Candidate: `fb74be3f9f81f2d9f00de073ef79bc395485b7ce` on + `integrate-community-prs` +- Scope: final committed integration tree, including the Taskboard preset menu, + Save dialog, Apply path, Manage editor, realtime and reconnect states, + responsive behavior, Usage Tracker Compact limit, and the current-main Host + Monitor reconciliation + +## Current finding + +### Finding UIUX-001: Retained-error retry controls need deterministic keyboard focus + +- Severity: low +- Category: keyboard navigation and async error recovery +- Location: `plugins/taskboard/app.tsx:2519-2532`, + `plugins/taskboard/app.tsx:6277-6291`, and + `plugins/taskboard/app.tsx:1661` +- Evidence: the Presets menu's retained-refresh `Try again` control is a plain + button nested inside Radix menu content rather than a registered + `DropdownMenuItem`. Radix's arrow-key collection therefore does not include + it, and menu Tab handling does not provide a reliable route to it. In both + that menu and Manage, starting the background retry immediately clears + `refreshError`, which removes the pressed retry control without assigning a + next focus target. +- Impact: pointer users can retry normally and the last authoritative preset + list remains usable, but a keyboard user may be unable to invoke the menu + retry or may lose their place after activating retry in Manage. Automatic + realtime/reconnect refresh remains available, so this is not a merge blocker. +- Recommendation: render the menu retry as a `DropdownMenuItem` so selection + closes back to the trigger, and either retain a disabled `Refreshing…` + control/status until the Manage request settles or move focus to the preset + heading/list before removing the retry button. +- Verification: with only a background refresh error present, reach and invoke + retry using keyboard alone in both full and constrained menus, then in + Manage; assert focus returns to a visible logical control and the retained + rows/drafts never unmount. + +## Final assessment + +No blocking UI, responsive, or accessibility finding remains. The final source +now establishes all of the following: + +- Full and constrained Presets menus share bounded scrolling, a fixed Save + action, accessible loading/error/empty states, and tracker-readiness guards. +- Applying a preset validates project and provider scope, replaces the complete + released browse-preference snapshot through the shared store, and announces + the named result. +- The Save dialog describes the captured filters/search/layout/collapse state, + prevents duplicate submission, keeps the entered name after failure, and + associates a persistent inline alert with the input. +- Realtime, reconnect, and post-mutation preset reconciliation is background, + revision- and project-scoped, and non-destructive. Failed refreshes preserve + authoritative rows and dirty rename drafts. +- Manage serializes rename/reorder/delete, announces success and failure, + restores logical focus after each mutation, restores adjacent focus after + deletion, and uses a wrapping `min-w-0` composition with coarse-pointer + sizing instead of horizontal overflow. +- Usage Tracker retains Weekly as the normalized default, updates the selected + compact reading without reload, chooses a fresh preferred or fresh + alternative window before last-known data, keeps both windows in expanded + details, and names configured, actual, last-known, and fallback states for + assistive technology. +- The Host Monitor merge has no Taskboard or Usage Tracker UI regression in the + reviewed working tree. + +Focused evidence passes: Taskboard typecheck and 109/109 tests, including the +new preset source guards; Usage Tracker typecheck and 17/17 tests, including +fresh-alternative ordering and distinct configured/fallback accessible text. +The focused Taskboard UI tests remain source-level guards, so the retry-focus +advisory above should receive a rendered keyboard check when it is addressed. diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/decisions.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/decisions.md new file mode 100644 index 0000000..63bdf38 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/decisions.md @@ -0,0 +1,167 @@ +# Decisions: Community PR Integration + +Record concise, externally reviewable evidence and choices here. Do not store +private chain-of-thought, prompts, credentials, secrets, or scratchpad text. + +## D-001: Integrate all PRs through one authorship-preserving branch + +Status: Accepted + +### Evidence + +PR #10 is independent. PR #12 is stacked directly on #11: #11 head `445e5ac` +is an ancestor of #12 head `91037db`, which adds seven commits. The repository +allows merge commits and each original commit carries the contributor identity. + +### Options + +- Squash or reimplement each PR independently. +- Merge #10, #11, and #12 in dependency order on one integration branch and + preserve original heads as parents. + +### Chosen approach + +Use normal merge parents in order #10, #11, #12, then merge the reviewed +integration branch normally into main. Never amend, squash, rebase, or force +push contributor commits. + +### Trade-offs and risks + +History gains intentional merge commits, but GitHub attribution and the true +stack relationship stay intact. An owner branch avoids risky writes to +contributors' forks. + +### Verification + +Check all three exact PR head SHAs are ancestors of the final main commit and +that Stephen Dolan/Andrii Los remain commit authors and visible README credits. + +## D-002: Merge PR #10 as submitted + +Status: Accepted + +### Evidence + +A synthetic merge against current main is conflict-free. Isolated root checks +pass with Usage Tracker 15/15 tests, and the change touches only seven Usage +Tracker source/docs/test files. Values are enum-constrained and normalized at +browser and RPC boundaries. + +### Options + +- Reimplement or defer the compact-limit setting. +- Retain the original commit through a normal merge. + +### Chosen approach + +Merge PR #10 normally and preserve Weekly as the default plus Five-hour as the +alternative, with runtime fallback when the preferred window is absent. + +### Trade-offs and risks + +The contributor did not add a DOM/RPC integration test, so live verification +remains necessary. No manifest version or release is created in this task. + +### Verification + +Run preference/usage tests, root checks, and live toggling that confirms compact +percentage/bar changes while expanded details continue to show both windows. + +## D-003: Record PR #11 with an ours merge + +Status: Accepted + +### Evidence + +Current main already satisfies the remembered-filter user outcome with a safer, +richer device-local architecture. PR #11's auto-merged non-conflicting files +would still introduce a second server writer, unbounded stale schema, collision, +races, and lost released fields even if conflict markers were resolved. + +### Options + +- Blindly merge and reconcile two persistence systems. +- Close #11 as superseded and lose merged status/ancestry. +- Use Git's whole-tree `ours` merge to retain authentic contributor history + while preserving current behavior. + +### Chosen approach + +Merge exact #11 head using `git merge -s ours --no-ff`; add Andrii Los credit +and assertions that the obsolete files/RPC/table remain absent. + +### Trade-offs and risks + +The contributor commits become history without their old implementation tree +entering current code. Visible credit and the review record explain why: their +requested outcome is already shipped, while the old mechanics are unsafe now. + +### Verification + +Diff the pre/post ours-merge trees, check forbidden symbols/files are absent, +exercise all released preference scopes, and retain the original commit authors. + +## D-004: Port PR #12 presets to current BrowsePreferences + +Status: Accepted + +### Evidence + +The seven unique preset commits provide valuable module/store/RPC/UI/CLI work, +but depend on #11's obsolete `BoardFilterState` and predate current Taskboard UI, +query/collapse/provider fields, Git-only manifest, and provider-safety changes. + +### Options + +- Merge the entire stale stack verbatim. +- Drop named presets. +- Merge the original preset commits, then adapt their boundaries in the + conflict-resolution commit. + +### Chosen approach + +Preserve the unique #12 commits as a merge parent and port preset state, +application, UI placement, RPC/CLI, and tests onto current +`browsePreferencesV1Schema` and observable store. + +### Trade-offs and risks + +The semantic resolution is substantial and must be independently reviewed. +It preserves contributor authorship while avoiding duplicate automatic state +ownership and an npm-era manifest regression. + +### Verification + +Test strict state/name/order/project behavior, UI save/apply/manage, CLI CRUD and +list precedence, root regression suite, live reload, and Git ancestry. + +## D-005: Credit contributors in history and documentation + +Status: Accepted + +### Evidence + +The repository has no existing Contributors convention. The user explicitly +requested contributor credit, and GitHub credit alone is not visible in product +documentation. + +### Options + +- Rely only on Git history. +- Add only generic thanks. +- Preserve commits and add named linked credits. + +### Chosen approach + +Add a root Contributors section linking Stephen Dolan and Andrii Los, plus +concise feature-specific credit in the relevant plugin documentation. + +### Trade-offs and risks + +Credits must stay factual and not imply ownership or endorsement beyond the +merged contributions. + +### Verification + +Assert exact GitHub links in documentation and original author emails/names in +reachable commits. diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/deltas/project-view-preferences.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/deltas/project-view-preferences.md new file mode 100644 index 0000000..acada9a --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/deltas/project-view-preferences.md @@ -0,0 +1,65 @@ +# Project View Preferences Delta + +## MODIFIED Requirements + +### Requirement: Project-scoped browse memory + +Taskboard SHALL retain one versioned, validated, device-local browse preference +record per BB project/provider plus the independent Across projects scope. It +SHALL additionally store explicitly named preset snapshots per BB project in the +plugin database. Presets SHALL reuse the complete released browse preference +shape and SHALL apply only on explicit user/CLI action through the ordinary +preference update path. + +#### Scenario: Apply a named preset + +- **GIVEN** project A has a preset containing query, filters, List/Kanban view, + and collapse overrides +- **WHEN** the user applies it from project A's filter bar +- **THEN** every preset field replaces project A's current browse state +- **AND** the full board and right panel immediately share the result +- **AND** project B and Across projects remain unchanged + +#### Scenario: Return later + +- **GIVEN** a preset was applied to project A +- **WHEN** the user reloads or returns from issue detail +- **THEN** the existing device-local project preference record restores the + applied state +- **AND** no separate server filter-state writer races with it + +## ADDED Requirements + +### Requirement: Safe project preset management + +Preset names and state SHALL be bounded and strictly validated. Names SHALL be +case-insensitively unique using locale-independent normalization; ordering SHALL +be deterministic and reorder writes SHALL require an exact permutation of the +visible project preset IDs. Corrupt rows SHALL be omitted individually, and +deleting an absent preset SHALL be idempotent. + +#### Scenario: Duplicate name + +- **WHEN** a user saves a preset whose normalized name already exists in the + same project +- **THEN** the write is rejected without modifying either preset + +#### Scenario: Corrupt stored preset + +- **WHEN** one stored row fails strict state validation +- **THEN** valid presets still list and can be reordered against the parseable + subset + +### Requirement: Preset CLI parity + +The Taskboard CLI SHALL list, save, rename, and delete project presets and SHALL +accept `list --preset `. Explicit source/query flags SHALL override the +preset's source/query; remaining preset facets SHALL use the same pure filter +function as the UI. + +#### Scenario: List with explicit override + +- **GIVEN** preset `My work` stores query `bug` and source `linear` +- **WHEN** the user runs `bb taskboard list --preset "My work" --query urgent` +- **THEN** `urgent` overrides the preset query +- **AND** the preset's remaining facets still narrow results diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/deltas/usage-tracker-compact-display.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/deltas/usage-tracker-compact-display.md new file mode 100644 index 0000000..bd455c5 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/deltas/usage-tracker-compact-display.md @@ -0,0 +1,33 @@ +# Usage Tracker Compact Display Delta + +## Purpose + +Define which provider usage window appears in the compact sidebar reading. + +## ADDED Requirements + +### Requirement: Configurable compact limit + +Usage Tracker SHALL let the user select Weekly or Five-hour as the compact +sidebar limit. Weekly SHALL be the default for new, legacy, missing, and unknown +preference values. The compact percentage and progress bar SHALL update when the +setting changes without hiding either window from expanded details. + +#### Scenario: Preferred window is available + +- **GIVEN** Weekly is selected and both windows are reported +- **WHEN** the compact provider reading renders +- **THEN** its percentage and progress bar use the weekly window +- **AND** expanded details still show both weekly and five-hour windows + +#### Scenario: Preferred window is missing + +- **GIVEN** Five-hour is selected but only the weekly window is reported +- **WHEN** the compact provider reading renders +- **THEN** it falls back to the weekly window rather than showing no limit + +#### Scenario: Legacy value is loaded + +- **WHEN** stored or RPC preference data omits or provides an unknown compact + limit +- **THEN** Usage Tracker normalizes the effective value to Weekly diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/design.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/design.md new file mode 100644 index 0000000..c0bf711 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/design.md @@ -0,0 +1,91 @@ +# Community PR Integration Design + +## Integration graph and branch + +Build one owner-controlled integration branch from current `origin/main` and +retain the original PR heads as merge parents. The exact order is #10, #11, +then #12: #10 is independent; #12 contains all six #11 commits and adds seven +preset commits. A non-squash merge of the final integration branch into main +makes the original contributor commits reachable and preserves GitHub credit. + +## PR #10: configurable compact Usage Tracker limit + +Merge `201c44a5f825c724fcfdfa6876437ceb2730814b` normally. It cleanly adds a +Weekly/Five-hour select setting, normalization at storage/RPC boundaries, live +compact-render selection and fallback, documentation, changelog, and focused +tests. Retain current private Git-only manifests and version. Confirm expanded +details remain independent of the compact selection. + +## PR #11: superseded filter persistence + +Current Taskboard already implements the requested remembered-filter outcome +with a richer versioned `BrowsePreferences` store: project/provider and Across +projects isolation, shared full/right-panel observation, query, view, all +facets, collapsed groups, storage-failure fallback, provider reconciliation, +and separate confirmed-assignee memory. + +PR #11's SQLite/RPC writer is incompatible: it omits version/provider/collapse +state, duplicates state ownership, creates last-write-wins cross-client races, +uses an unbounded pre-release schema, and can reintroduce npm-era package +metadata. Merge its exact head +`445e5ac4f3943a8eb4821f27a808715f46e57450` with Git's `ours` strategy, not +`-X ours`, so its six author commits become ancestors while the released +implementation tree remains intact. Add visible Andrii Los credit and a guard +proving no `project_filter_state`, `filter-state.ts`, or filter-state RPC path +survives. + +## PR #12: port named presets onto BrowsePreferences + +After the #11 merge parent exists, merge PR #12 head +`91037db6add4edd08c313025cb8a1e51896458ad`; its merge base becomes #11 and the +semantic delta is the seven preset commits only. Preserve the new preset module, +SQLite CRUD/migration, RPC/CLI behavior, UI affordances, tests, and docs, but +adapt every preset state boundary from obsolete `BoardFilterState` to the +released strict `BrowsePreferences` schema. + +### State and storage + +Persist the complete `BrowsePreferences` record, including version, provider, +query, source, view, facets, and collapse overrides. Validate before every write +and after every read. Scope every query by the validated BB project id. Bound +names, state arrays/strings, row counts, and request payloads through the +existing schema plus preset limits. Keep corrupt-row omission, locale-independent +name normalization, uniqueness, deterministic positions, exact-permutation +reorder, and idempotent delete. + +### UI + +Add one Presets control to the existing filter presentation without restoring +the contributor branch's older filter-bar markup. Save reads the current +observable preference snapshot. Apply calls the existing store's `set` for the +current project scope so full and right-panel surfaces update immediately and +the ordinary device-local persistence path records the result. Manage owns +rename/reorder/delete with serialized mutations and authoritative RPC results. +Across projects does not expose project presets. + +### CLI and RPC + +Port the contributor's preset RPC contract and handlers into the current +contract/server composition. CLI preset CRUD resolves names case-insensitively. +`list --preset` applies explicit source/query precedence, then sends remaining +facets through current `filterWorkItemsByAttributes`; output stays bounded and +project/provider checks remain authoritative. + +## Contributor credit + +Add a root README Contributors section for Stephen Dolan (`@stephendolan`) and +Andrii Los (`@RIP21`) and plugin-specific credits beside their documented +features. Preserve original commits via merge parents; do not squash or amend +them. Existing Claude co-author metadata on #11 remains historical. + +## Verification and delivery + +Run focused Usage Tracker and preset tests during resolution, then the root +check and build-metadata validation. Reload both live path plugins only after +the candidate passes, exercise Compact limit and preset CRUD/apply/list, and +inspect logs/status. Independent review checks security, migrations, state +bounds, existing feature regressions, and ancestry. Empirical integrates the +capability deltas on a detached current-main target. Push the reviewed +integration branch, require hosted CI, merge normally, then verify all three PR +heads are ancestors of main and their PRs show merged (or close with an exact +integration reference if GitHub does not auto-detect). diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/events/00000011.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/events/00000011.json new file mode 100644 index 0000000..cf48815 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/events/00000011.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "sequence": 11, + "previousDigest": "sha256:ae0b4ba0a0eba0fcf38b385000b235638163d7719d396db0f15f40a3365de1eb", + "actor": "empirical-integrate", + "type": "compaction-boundary", + "summary": "Compacted through journal event 10", + "createdAt": "2026-08-27T03:34:05.693Z", + "stateBeforeDigest": "sha256:2bcbb735af1f0c95d9509bcc65fc797e2407e3e8e6105e551e096d365fcca70e", + "stateAfterDigest": "sha256:2bcbb735af1f0c95d9509bcc65fc797e2407e3e8e6105e551e096d365fcca70e", + "state": { + "schemaVersion": 5, + "revision": 10, + "activeFeature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "request": "Review open PRs #10, #11, and #12 in MateoCerquetella/bb-plugins; resolve their merge conflicts against current main without regressing the released Taskboard and Usage Tracker behavior; preserve original contributor authorship, add the contributors to repository credits, run the complete checks, and prepare all safe PRs for merge into main.", + "profile": "complex", + "workflow": "complex", + "mode": "yolo", + "phase": "done", + "status": "done", + "repairAttempts": 0, + "message": "Integrated capability changes: project-view-preferences, usage-tracker-compact-display", + "implementationActor": "agent", + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "approvedSpecRevision": 3, + "capabilityArchiveRequired": true, + "capabilityDeltaDigest": "sha256:1e403bb79f025c25027652bd89253ec16aa00d6f825a26d6b0e96fc55381e1aa", + "impactDigest": "sha256:f68b67f7fc667b720cf9433b7aa0069ae03a0c96706d220157b7a808f3b93430", + "capabilityClaimId": "review-open-prs-10-11-and-12-in-mateocerquetella-bb-59becfd36252", + "authorizationDigest": "sha256:8e131cdb10ceb4f25b9b720581e879a03c6bbaf5b3ae32c40a47ade191d29a19", + "evidence": [], + "evidenceReceiptIds": [ + "executed-13777e8d218261389af72cdf", + "collected-71b3cbfb605f5596ddbf3ae4", + "collected-c1ed7405d4b6b355f5f140e3" + ], + "legacyEvidenceCount": 0, + "completion": { + "implemented": true, + "verified": true, + "integrated": true, + "delivered": false, + "published": false, + "highest": "integrated", + "reasons": { + "delivered": "delivered has not been proven by a durable receipt.", + "published": "published has not been proven by a durable receipt." + } + }, + "updatedAt": "2026-08-27T03:34:05.677Z" + }, + "digest": "sha256:a3309bd8890741aa68c5d426db8c64ee9dc4c9c906ec97d05bb84830b0b97bbb" +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/events/snapshot.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/events/snapshot.json new file mode 100644 index 0000000..d7bc5b8 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/events/snapshot.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 1, + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "lastSequence": 10, + "lastEventDigest": "sha256:ae0b4ba0a0eba0fcf38b385000b235638163d7719d396db0f15f40a3365de1eb", + "stateDigest": "sha256:2bcbb735af1f0c95d9509bcc65fc797e2407e3e8e6105e551e096d365fcca70e", + "state": { + "schemaVersion": 5, + "revision": 10, + "activeFeature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "request": "Review open PRs #10, #11, and #12 in MateoCerquetella/bb-plugins; resolve their merge conflicts against current main without regressing the released Taskboard and Usage Tracker behavior; preserve original contributor authorship, add the contributors to repository credits, run the complete checks, and prepare all safe PRs for merge into main.", + "profile": "complex", + "workflow": "complex", + "mode": "yolo", + "phase": "done", + "status": "done", + "repairAttempts": 0, + "message": "Integrated capability changes: project-view-preferences, usage-tracker-compact-display", + "implementationActor": "agent", + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "approvedSpecRevision": 3, + "capabilityArchiveRequired": true, + "capabilityDeltaDigest": "sha256:1e403bb79f025c25027652bd89253ec16aa00d6f825a26d6b0e96fc55381e1aa", + "impactDigest": "sha256:f68b67f7fc667b720cf9433b7aa0069ae03a0c96706d220157b7a808f3b93430", + "capabilityClaimId": "review-open-prs-10-11-and-12-in-mateocerquetella-bb-59becfd36252", + "authorizationDigest": "sha256:8e131cdb10ceb4f25b9b720581e879a03c6bbaf5b3ae32c40a47ade191d29a19", + "evidence": [], + "evidenceReceiptIds": [ + "executed-13777e8d218261389af72cdf", + "collected-71b3cbfb605f5596ddbf3ae4", + "collected-c1ed7405d4b6b355f5f140e3" + ], + "legacyEvidenceCount": 0, + "completion": { + "implemented": true, + "verified": true, + "integrated": true, + "delivered": false, + "published": false, + "highest": "integrated", + "reasons": { + "delivered": "delivered has not been proven by a durable receipt.", + "published": "published has not been proven by a durable receipt." + } + }, + "updatedAt": "2026-08-27T03:34:05.677Z" + }, + "compactedAt": "2026-08-27T03:34:05.693Z", + "digest": "sha256:e4202bcbf00b46479b2f9559eb90a839af8e50bed16ece71a0799832c8ab7c85" +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipt-input.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipt-input.json new file mode 100644 index 0000000..291f754 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipt-input.json @@ -0,0 +1,20 @@ +{ + "criteria": ["AC-1", "AC-2", "AC-3", "AC-4", "AC-5", "AC-6", "AC-7", "AC-8", "AC-9"], + "evidenceKinds": ["test"], + "summary": "Verified the final authorship-preserving integration of PRs #10, #11, and #12 against latest main: compact Usage behavior, complete bounded project presets across SQLite/RPC/CLI/UI, released preference/provider/distribution regressions, contributor ancestry/credits, concurrent Host Monitor preservation, root checks, and live Taskboard activation.", + "collector": "codex", + "artifacts": [ + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/verification.md", + "mediaType": "text/markdown" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md", + "mediaType": "text/markdown" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md", + "mediaType": "text/markdown" + } + ] +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/collected-71b3cbfb605f5596ddbf3ae4.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/collected-71b3cbfb605f5596ddbf3ae4.json new file mode 100644 index 0000000..7d2f9b0 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/collected-71b3cbfb605f5596ddbf3ae4.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "id": "collected-71b3cbfb605f5596ddbf3ae4", + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-4", + "AC-5", + "AC-6", + "AC-7", + "AC-8", + "AC-9" + ], + "evidenceKinds": [ + "test" + ], + "summary": "Verified the final authorship-preserving integration of PRs #10, #11, and #12 against latest main: compact Usage behavior, complete bounded project presets across SQLite/RPC/CLI/UI, released preference/provider/distribution regressions, contributor ancestry/credits, concurrent Host Monitor preservation, root checks, and live Taskboard activation.", + "passed": true, + "startedAt": "2026-08-27T03:32:48.654Z", + "completedAt": "2026-08-27T03:32:48.654Z", + "provenance": { + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "specRevision": 3, + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "treeDigest": "sha256:322e4afd46bb1c33727872f8c76d07ca0bd57d4f2c59c52112171b2d3f5f8f6b", + "policyDigest": "sha256:07832222126a8e5297c6f9da9d153a26653bb8b3f3665dd024d61fc8ca82436e" + }, + "digest": "sha256:a3fa6d12a0f8ffe277a4a19db001714e0efcc4ca342e4aae2aad8fdc482b1a4e", + "kind": "collected", + "collector": "codex", + "artifacts": [ + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md", + "mediaType": "text/markdown", + "bytes": 4531, + "digest": "sha256:baae020ced0d53dba03606c972ee0c027f654474d5ebdfadfc9d528d20ccd5cf" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md", + "mediaType": "text/markdown", + "bytes": 4022, + "digest": "sha256:e2ad3170b22d0b92d6b8e22dba698ee1a95299da4abc07e384f550e6f86253c1" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/verification.md", + "mediaType": "text/markdown", + "bytes": 5859, + "digest": "sha256:95d165a2e9a711056c93b86ebe15eee3e0af0bfbbf6cbe2a24397ddc60100116" + } + ] +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/collected-c1ed7405d4b6b355f5f140e3.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/collected-c1ed7405d4b6b355f5f140e3.json new file mode 100644 index 0000000..f2037f7 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/collected-c1ed7405d4b6b355f5f140e3.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "id": "collected-c1ed7405d4b6b355f5f140e3", + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-4", + "AC-5", + "AC-6", + "AC-7", + "AC-8", + "AC-9" + ], + "evidenceKinds": [ + "review" + ], + "summary": "Independent final code, security, and UI/UX reviews covered every acceptance criterion, accepted decision, contributor head, conflict resolution, concurrent Host Monitor merge, and current final candidate; no blocking, high, or medium finding remains.", + "passed": true, + "startedAt": "2026-08-27T03:33:11.362Z", + "completedAt": "2026-08-27T03:33:11.362Z", + "provenance": { + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "specRevision": 3, + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "treeDigest": "sha256:322e4afd46bb1c33727872f8c76d07ca0bd57d4f2c59c52112171b2d3f5f8f6b", + "policyDigest": "sha256:07832222126a8e5297c6f9da9d153a26653bb8b3f3665dd024d61fc8ca82436e" + }, + "digest": "sha256:f4fbaea7886b6546c6a6e4a76fe921536466a32a7296033aa12f7cb0ff011779", + "kind": "collected", + "collector": "codex", + "artifacts": [ + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/code-review.md", + "mediaType": "text/markdown", + "bytes": 8773, + "digest": "sha256:b13628da27c4bb10ce9184e7d6397b2b158c94c4f2dc5a1872fdcd5fe8e11817" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md", + "mediaType": "text/markdown", + "bytes": 4531, + "digest": "sha256:baae020ced0d53dba03606c972ee0c027f654474d5ebdfadfc9d528d20ccd5cf" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md", + "mediaType": "text/markdown", + "bytes": 4022, + "digest": "sha256:e2ad3170b22d0b92d6b8e22dba698ee1a95299da4abc07e384f550e6f86253c1" + } + ] +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-13777e8d218261389af72cdf.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-13777e8d218261389af72cdf.json new file mode 100644 index 0000000..30f65ce --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-13777e8d218261389af72cdf.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "id": "executed-13777e8d218261389af72cdf", + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-4", + "AC-5", + "AC-6", + "AC-7", + "AC-8", + "AC-9" + ], + "evidenceKinds": [ + "test" + ], + "summary": "Run the final root check for the authorship-preserving PR #10/#11/#12 integration: Usage Tracker compact-limit behavior, Taskboard current preference regressions, bounded named preset schema/SQLite/RPC/CLI/UI guards, Git-only manifests, contributor docs, builds, and metadata.", + "passed": true, + "startedAt": "2026-08-27T03:25:00.981Z", + "completedAt": "2026-08-27T03:25:19.506Z", + "provenance": { + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "specRevision": 3, + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "treeDigest": "sha256:322e4afd46bb1c33727872f8c76d07ca0bd57d4f2c59c52112171b2d3f5f8f6b", + "policyDigest": "sha256:07832222126a8e5297c6f9da9d153a26653bb8b3f3665dd024d61fc8ca82436e" + }, + "digest": "sha256:bf25a7bccfe61f98985c815d2f9ff4e1073d844c1d09c3b0fcb410ca93c2622a", + "kind": "executed", + "command": { + "argv": [ + "npm", + "run", + "check" + ], + "cwd": ".", + "timeoutMs": 900000, + "maxOutputBytes": 1048576, + "environmentKeys": [ + "NO_COLOR", + "PATH" + ] + }, + "result": { + "exitCode": 0, + "signal": null, + "timedOut": false, + "stdoutDigest": "sha256:f36f9f6ae2170a57cef6f5f7d536dc4aa4ecca8440f96caa2ac1c7439f5d758b", + "stderrDigest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stdoutTail": "est: builds a read-only repository-aware worker prompt\nok 90 - builds a read-only repository-aware worker prompt\n ---\n duration_ms: 0.237543\n type: 'test'\n ...\n# Subtest: keeps the original prompt as an editable fallback\nok 91 - keeps the original prompt as an editable fallback\n ---\n duration_ms: 0.240008\n type: 'test'\n ...\n# Subtest: selects the active thread project only when it still exists\nok 92 - selects the active thread project only when it still exists\n ---\n duration_ms: 0.640458\n type: 'test'\n ...\n# Subtest: treats a different thread in the same project as new context\nok 93 - treats a different thread in the same project as new context\n ---\n duration_ms: 0.236361\n type: 'test'\n ...\n# Subtest: reads the project and thread from a BB project route\nok 94 - reads the project and thread from a BB project route\n ---\n duration_ms: 0.638364\n type: 'test'\n ...\n# Subtest: uses only the immediately previous navigation entry as source context\nok 95 - uses only the immediately previous navigation entry as source context\n ---\n duration_ms: 0.197961\n type: 'test'\n ...\n# Subtest: never replaces an explicit Taskboard project selection with route context\nok 96 - never replaces an explicit Taskboard project selection with route context\n ---\n duration_ms: 0.144052\n type: 'test'\n ...\n# Subtest: writes project credentials atomically with owner-only permissions\nok 97 - writes project credentials atomically with owner-only permissions\n ---\n duration_ms: 6.8536\n type: 'test'\n ...\n# Subtest: deleting a missing credential is harmless\nok 98 - deleting a missing credential is harmless\n ---\n duration_ms: 0.988242\n type: 'test'\n ...\n# Subtest: cached summaries never retain provider comments\nok 99 - cached summaries never retain provider comments\n ---\n duration_ms: 1.155057\n type: 'test'\n ...\n# Subtest: provider creation results carry strict native assignee confirmation\nok 100 - provider creation results carry strict native assignee confirmation\n ---\n duration_ms: 0.123324\n type: 'test'\n ...\n# Subtest: metadata failures expose only a fixed server-safe message\nok 101 - metadata failures expose only a fixed server-safe message\n ---\n duration_ms: 0.245337\n type: 'test'\n ...\n# Subtest: finds Jira project keys from common configured JQL scopes\nok 102 - finds Jira project keys from common configured JQL scopes\n ---\n duration_ms: 0.650256\n type: 'test'\n ...\n# Subtest: binds issue creation to the tracker reviewed in the modal\nok 103 - binds issue creation to the tracker reviewed in the modal\n ---\n duration_ms: 0.348385\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after successful issue creation\nok 104 - refreshes authoritative provider data after successful issue creation\n ---\n duration_ms: 0.200275\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after ambiguous issue creation failure\nok 105 - refreshes authoritative provider data after ambiguous issue creation failure\n ---\n duration_ms: 0.207609\n type: 'test'\n ...\n# Subtest: keeps structural headers on neutral host theme surfaces\nok 106 - keeps structural headers on neutral host theme surfaces\n ---\n duration_ms: 1.518349\n type: 'test'\n ...\n# Subtest: uses restrained state glyph, focus, and conversation treatments\nok 107 - uses restrained state glyph, focus, and conversation treatments\n ---\n duration_ms: 0.342895\n type: 'test'\n ...\n# Subtest: keeps constrained filter values inside the vertical menu measure\nok 108 - keeps constrained filter values inside the vertical menu measure\n ---\n duration_ms: 0.251078\n type: 'test'\n ...\n# Subtest: keeps assignee avatars compact with six theme-safe identity tones\nok 109 - keeps assignee avatars compact with six theme-safe identity tones\n ---\n duration_ms: 0.397715\n type: 'test'\n ...\n1..109\n# tests 109\n# suites 0\n# pass 109\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 410.131034\n\n> bb-plugin-taskboard@0.3.1 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n\n> bb-plugin-taskboard@0.3.1 verify:build\n> node scripts/verify-build.mjs\n\nTaskboard build metadata is valid.\n\n> bb-plugin-usage-tracker@0.1.2 check\n> npm run types:check && npm run typecheck && npm test && npm run build\n\n\n> bb-plugin-usage-tracker@0.1.2 types:check\n> env -u BB_CLI bb plugin types --check .\n\nThis plugin uses the npm package @get-bb/plugin-sdk; pin is 0.4.6, host is 0.4.6.\nThe declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.\n\n> bb-plugin-usage-tracker@0.1.2 typecheck\n> tsc --noEmit\n\n\n> bb-plugin-usage-tracker@0.1.2 test\n> node --test --experimental-strip-types test/*.test.ts\n\nTAP version 13\n# Subtest: enables sidebar providers independently in display order\nok 1 - enables sidebar providers independently in display order\n ---\n duration_ms: 1.026593\n type: 'test'\n ...\n# Subtest: normalizes compact limit preferences to the weekly default\nok 2 - normalizes compact limit preferences to the weekly default\n ---\n duration_ms: 0.127543\n type: 'test'\n ...\n# Subtest: normalizes providers in stable order with every usage window\nok 3 - normalizes providers in stable order with every usage window\n ---\n duration_ms: 0.531189\n type: 'test'\n ...\n# Subtest: normalizes not-installed and provider-error states\nok 4 - normalizes not-installed and provider-error states\n ---\n duration_ms: 0.207468\n type: 'test'\n ...\n# Subtest: clamps progress geometry and rejects non-finite values\nok 5 - clamps progress geometry and rejects non-finite values\n ---\n duration_ms: 0.319543\n type: 'test'\n ...\n# Subtest: formats reset, update, percentage, and cost copy safely\nok 6 - formats reset, update, percentage, and cost copy safely\n ---\n duration_ms: 19.295772\n type: 'test'\n ...\n# Subtest: selects the configured compact usage window\nok 7 - selects the configured compact usage window\n ---\n duration_ms: 0.499421\n type: 'test'\n ...\n# Subtest: falls back when the configured compact window is unavailable\nok 8 - falls back when the configured compact window is unavailable\n ---\n duration_ms: 0.27405\n type: 'test'\n ...\n# Subtest: prefers a fresh alternative before merged last-known compact windows\nok 9 - prefers a fresh alternative before merged last-known compact windows\n ---\n duration_ms: 0.307652\n type: 'test'\n ...\n# Subtest: describes configured, actual, and fallback compact windows accessibly\nok 10 - describes configured, actual, and fallback compact windows accessibly\n ---\n duration_ms: 0.588793\n type: 'test'\n ...\n# Subtest: keeps last-known sidebar windows through partial and failed refreshes\nok 11 - keeps last-known sidebar windows through partial and failed refreshes\n ---\n duration_ms: 0.240048\n type: 'test'\n ...\n# Subtest: resolves the thread environment host\nok 12 - resolves the thread environment host\n ---\n duration_ms: 0.159792\n type: 'test'\n ...\n# Subtest: falls back when a thread has no environment or lookup fails\nok 13 - falls back when a thread has no environment or lookup fails\n ---\n duration_ms: 0.138854\n type: 'test'\n ...\n# Subtest: loads usage for the resolved host and tolerates missing host metadata\nok 14 - loads usage for the resolved host and tolerates missing host metadata\n ---\n duration_ms: 0.237193\n type: 'test'\n ...\n# Subtest: omits host override for primary-machine fallback\nok 15 - omits host override for primary-machine fallback\n ---\n duration_ms: 0.124908\n type: 'test'\n ...\n# Subtest: loads the primary machine directly for the sidebar strip\nok 16 - loads the primary machine directly for the sidebar strip\n ---\n duration_ms: 0.102988\n type: 'test'\n ...\n# Subtest: propagates thread and request-level usage failures\nok 17 - propagates thread and request-level usage failures\n ---\n duration_ms: 0.208421\n type: 'test'\n ...\n1..17\n# tests 17\n# suites 0\n# pass 17\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 109.762333\n\n> bb-plugin-usage-tracker@0.1.2 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n", + "stderrTail": "", + "stdoutTruncated": false, + "stderrTruncated": false + } +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-43b93489c545e40cc8b72355.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-43b93489c545e40cc8b72355.json new file mode 100644 index 0000000..785ea4a --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-43b93489c545e40cc8b72355.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "id": "executed-43b93489c545e40cc8b72355", + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-4", + "AC-5", + "AC-6", + "AC-7", + "AC-8", + "AC-9" + ], + "evidenceKinds": [ + "test" + ], + "summary": "Run the final root check for the authorship-preserving PR #10/#11/#12 integration: Usage Tracker compact-limit behavior, Taskboard current preference regressions, bounded named preset schema/SQLite/RPC/CLI/UI guards, Git-only manifests, contributor docs, builds, and metadata.", + "passed": true, + "startedAt": "2026-08-27T03:15:30.534Z", + "completedAt": "2026-08-27T03:15:51.538Z", + "provenance": { + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "specRevision": 3, + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "treeDigest": "sha256:a00d5c7774ca75532d96ebac362a39bf7ffc089293248c456c9fd96e037a8f83", + "policyDigest": "sha256:07832222126a8e5297c6f9da9d153a26653bb8b3f3665dd024d61fc8ca82436e" + }, + "digest": "sha256:efa465ee77d11352de5d1e161be4a1c0e4b33e2a27604bff7d8f7b3687bdfc5f", + "kind": "executed", + "command": { + "argv": [ + "npm", + "run", + "check" + ], + "cwd": ".", + "timeoutMs": 900000, + "maxOutputBytes": 1048576, + "environmentKeys": [ + "NO_COLOR", + "PATH" + ] + }, + "result": { + "exitCode": 0, + "signal": null, + "timedOut": false, + "stdoutDigest": "sha256:1930df4773a843c8aa034038e78050df7ecd2d3a53de2edc735a7b42b46977f2", + "stderrDigest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stdoutTail": "..\n# Subtest: builds a read-only repository-aware worker prompt\nok 90 - builds a read-only repository-aware worker prompt\n ---\n duration_ms: 0.177674\n type: 'test'\n ...\n# Subtest: keeps the original prompt as an editable fallback\nok 91 - keeps the original prompt as an editable fallback\n ---\n duration_ms: 0.1974\n type: 'test'\n ...\n# Subtest: selects the active thread project only when it still exists\nok 92 - selects the active thread project only when it still exists\n ---\n duration_ms: 0.987079\n type: 'test'\n ...\n# Subtest: treats a different thread in the same project as new context\nok 93 - treats a different thread in the same project as new context\n ---\n duration_ms: 0.220011\n type: 'test'\n ...\n# Subtest: reads the project and thread from a BB project route\nok 94 - reads the project and thread from a BB project route\n ---\n duration_ms: 0.885955\n type: 'test'\n ...\n# Subtest: uses only the immediately previous navigation entry as source context\nok 95 - uses only the immediately previous navigation entry as source context\n ---\n duration_ms: 0.281683\n type: 'test'\n ...\n# Subtest: never replaces an explicit Taskboard project selection with route context\nok 96 - never replaces an explicit Taskboard project selection with route context\n ---\n duration_ms: 0.207969\n type: 'test'\n ...\n# Subtest: writes project credentials atomically with owner-only permissions\nok 97 - writes project credentials atomically with owner-only permissions\n ---\n duration_ms: 17.181916\n type: 'test'\n ...\n# Subtest: deleting a missing credential is harmless\nok 98 - deleting a missing credential is harmless\n ---\n duration_ms: 1.127235\n type: 'test'\n ...\n# Subtest: cached summaries never retain provider comments\nok 99 - cached summaries never retain provider comments\n ---\n duration_ms: 2.643478\n type: 'test'\n ...\n# Subtest: provider creation results carry strict native assignee confirmation\nok 100 - provider creation results carry strict native assignee confirmation\n ---\n duration_ms: 0.221724\n type: 'test'\n ...\n# Subtest: metadata failures expose only a fixed server-safe message\nok 101 - metadata failures expose only a fixed server-safe message\n ---\n duration_ms: 0.39517\n type: 'test'\n ...\n# Subtest: finds Jira project keys from common configured JQL scopes\nok 102 - finds Jira project keys from common configured JQL scopes\n ---\n duration_ms: 0.786123\n type: 'test'\n ...\n# Subtest: binds issue creation to the tracker reviewed in the modal\nok 103 - binds issue creation to the tracker reviewed in the modal\n ---\n duration_ms: 0.613117\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after successful issue creation\nok 104 - refreshes authoritative provider data after successful issue creation\n ---\n duration_ms: 0.291451\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after ambiguous issue creation failure\nok 105 - refreshes authoritative provider data after ambiguous issue creation failure\n ---\n duration_ms: 0.303534\n type: 'test'\n ...\n# Subtest: keeps structural headers on neutral host theme surfaces\nok 106 - keeps structural headers on neutral host theme surfaces\n ---\n duration_ms: 0.83936\n type: 'test'\n ...\n# Subtest: uses restrained state glyph, focus, and conversation treatments\nok 107 - uses restrained state glyph, focus, and conversation treatments\n ---\n duration_ms: 0.237583\n type: 'test'\n ...\n# Subtest: keeps constrained filter values inside the vertical menu measure\nok 108 - keeps constrained filter values inside the vertical menu measure\n ---\n duration_ms: 0.17546\n type: 'test'\n ...\n# Subtest: keeps assignee avatars compact with six theme-safe identity tones\nok 109 - keeps assignee avatars compact with six theme-safe identity tones\n ---\n duration_ms: 0.2742\n type: 'test'\n ...\n1..109\n# tests 109\n# suites 0\n# pass 109\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 424.774847\n\n> bb-plugin-taskboard@0.3.1 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n\n> bb-plugin-taskboard@0.3.1 verify:build\n> node scripts/verify-build.mjs\n\nTaskboard build metadata is valid.\n\n> bb-plugin-usage-tracker@0.1.2 check\n> npm run types:check && npm run typecheck && npm test && npm run build\n\n\n> bb-plugin-usage-tracker@0.1.2 types:check\n> env -u BB_CLI bb plugin types --check .\n\nThis plugin uses the npm package @get-bb/plugin-sdk; pin is 0.4.6, host is 0.4.6.\nThe declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.\n\n> bb-plugin-usage-tracker@0.1.2 typecheck\n> tsc --noEmit\n\n\n> bb-plugin-usage-tracker@0.1.2 test\n> node --test --experimental-strip-types test/*.test.ts\n\nTAP version 13\n# Subtest: enables sidebar providers independently in display order\nok 1 - enables sidebar providers independently in display order\n ---\n duration_ms: 1.11963\n type: 'test'\n ...\n# Subtest: normalizes compact limit preferences to the weekly default\nok 2 - normalizes compact limit preferences to the weekly default\n ---\n duration_ms: 0.136799\n type: 'test'\n ...\n# Subtest: normalizes providers in stable order with every usage window\nok 3 - normalizes providers in stable order with every usage window\n ---\n duration_ms: 1.214904\n type: 'test'\n ...\n# Subtest: normalizes not-installed and provider-error states\nok 4 - normalizes not-installed and provider-error states\n ---\n duration_ms: 0.20831\n type: 'test'\n ...\n# Subtest: clamps progress geometry and rejects non-finite values\nok 5 - clamps progress geometry and rejects non-finite values\n ---\n duration_ms: 0.308192\n type: 'test'\n ...\n# Subtest: formats reset, update, percentage, and cost copy safely\nok 6 - formats reset, update, percentage, and cost copy safely\n ---\n duration_ms: 14.981626\n type: 'test'\n ...\n# Subtest: selects the configured compact usage window\nok 7 - selects the configured compact usage window\n ---\n duration_ms: 0.516\n type: 'test'\n ...\n# Subtest: falls back when the configured compact window is unavailable\nok 8 - falls back when the configured compact window is unavailable\n ---\n duration_ms: 0.212017\n type: 'test'\n ...\n# Subtest: prefers a fresh alternative before merged last-known compact windows\nok 9 - prefers a fresh alternative before merged last-known compact windows\n ---\n duration_ms: 0.322277\n type: 'test'\n ...\n# Subtest: describes configured, actual, and fallback compact windows accessibly\nok 10 - describes configured, actual, and fallback compact windows accessibly\n ---\n duration_ms: 0.558147\n type: 'test'\n ...\n# Subtest: keeps last-known sidebar windows through partial and failed refreshes\nok 11 - keeps last-known sidebar windows through partial and failed refreshes\n ---\n duration_ms: 0.257059\n type: 'test'\n ...\n# Subtest: resolves the thread environment host\nok 12 - resolves the thread environment host\n ---\n duration_ms: 0.15348\n type: 'test'\n ...\n# Subtest: falls back when a thread has no environment or lookup fails\nok 13 - falls back when a thread has no environment or lookup fails\n ---\n duration_ms: 0.138222\n type: 'test'\n ...\n# Subtest: loads usage for the resolved host and tolerates missing host metadata\nok 14 - loads usage for the resolved host and tolerates missing host metadata\n ---\n duration_ms: 0.273839\n type: 'test'\n ...\n# Subtest: omits host override for primary-machine fallback\nok 15 - omits host override for primary-machine fallback\n ---\n duration_ms: 0.150755\n type: 'test'\n ...\n# Subtest: loads the primary machine directly for the sidebar strip\nok 16 - loads the primary machine directly for the sidebar strip\n ---\n duration_ms: 0.121211\n type: 'test'\n ...\n# Subtest: propagates thread and request-level usage failures\nok 17 - propagates thread and request-level usage failures\n ---\n duration_ms: 0.230951\n type: 'test'\n ...\n1..17\n# tests 17\n# suites 0\n# pass 17\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 106.079543\n\n> bb-plugin-usage-tracker@0.1.2 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n", + "stderrTail": "", + "stdoutTruncated": false, + "stderrTruncated": false + } +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-ae943858d4b08159c467ff57.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-ae943858d4b08159c467ff57.json new file mode 100644 index 0000000..4ddf73e --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-ae943858d4b08159c467ff57.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "id": "executed-ae943858d4b08159c467ff57", + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-4", + "AC-5", + "AC-6", + "AC-7", + "AC-8", + "AC-9" + ], + "evidenceKinds": [ + "test" + ], + "summary": "Run the final root check for the authorship-preserving PR #10/#11/#12 integration: Usage Tracker compact-limit behavior, Taskboard current preference regressions, bounded named preset schema/SQLite/RPC/CLI/UI guards, Git-only manifests, contributor docs, builds, and metadata.", + "passed": true, + "startedAt": "2026-08-27T02:44:26.772Z", + "completedAt": "2026-08-27T02:44:44.824Z", + "provenance": { + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "specRevision": 3, + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "treeDigest": "sha256:f0bca0309648b1d866460904e5ac8b3d98e4ad2661437c473487222baaa9e127", + "policyDigest": "sha256:07832222126a8e5297c6f9da9d153a26653bb8b3f3665dd024d61fc8ca82436e" + }, + "digest": "sha256:b2331d11687ec4abc50a9bf6ca9c21abc6bd47a3bacd1f9b4645f16190eb89d6", + "kind": "executed", + "command": { + "argv": [ + "npm", + "run", + "check" + ], + "cwd": ".", + "timeoutMs": 900000, + "maxOutputBytes": 1048576, + "environmentKeys": [ + "NO_COLOR", + "PATH" + ] + }, + "result": { + "exitCode": 0, + "signal": null, + "timedOut": false, + "stdoutDigest": "sha256:2f510d6fa25a7bed3819dc079c2da2ef7532db7e03c5947be4e4d5d79760b210", + "stderrDigest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stdoutTail": "only exact preset order permutations\n ---\n duration_ms: 0.28531\n type: 'test'\n ...\n# Subtest: parses a tagged model response into a ticket draft\nok 81 - parses a tagged model response into a ticket draft\n ---\n duration_ms: 1.767782\n type: 'test'\n ...\n# Subtest: rejects malformed or incomplete model output\nok 82 - rejects malformed or incomplete model output\n ---\n duration_ms: 0.633365\n type: 'test'\n ...\n# Subtest: builds a read-only repository-aware worker prompt\nok 83 - builds a read-only repository-aware worker prompt\n ---\n duration_ms: 0.178536\n type: 'test'\n ...\n# Subtest: keeps the original prompt as an editable fallback\nok 84 - keeps the original prompt as an editable fallback\n ---\n duration_ms: 0.167325\n type: 'test'\n ...\n# Subtest: selects the active thread project only when it still exists\nok 85 - selects the active thread project only when it still exists\n ---\n duration_ms: 3.123985\n type: 'test'\n ...\n# Subtest: treats a different thread in the same project as new context\nok 86 - treats a different thread in the same project as new context\n ---\n duration_ms: 0.26331\n type: 'test'\n ...\n# Subtest: reads the project and thread from a BB project route\nok 87 - reads the project and thread from a BB project route\n ---\n duration_ms: 0.803063\n type: 'test'\n ...\n# Subtest: uses only the immediately previous navigation entry as source context\nok 88 - uses only the immediately previous navigation entry as source context\n ---\n duration_ms: 0.328018\n type: 'test'\n ...\n# Subtest: never replaces an explicit Taskboard project selection with route context\nok 89 - never replaces an explicit Taskboard project selection with route context\n ---\n duration_ms: 0.142389\n type: 'test'\n ...\n# Subtest: writes project credentials atomically with owner-only permissions\nok 90 - writes project credentials atomically with owner-only permissions\n ---\n duration_ms: 6.383266\n type: 'test'\n ...\n# Subtest: deleting a missing credential is harmless\nok 91 - deleting a missing credential is harmless\n ---\n duration_ms: 0.867231\n type: 'test'\n ...\n# Subtest: cached summaries never retain provider comments\nok 92 - cached summaries never retain provider comments\n ---\n duration_ms: 1.065151\n type: 'test'\n ...\n# Subtest: provider creation results carry strict native assignee confirmation\nok 93 - provider creation results carry strict native assignee confirmation\n ---\n duration_ms: 0.149402\n type: 'test'\n ...\n# Subtest: metadata failures expose only a fixed server-safe message\nok 94 - metadata failures expose only a fixed server-safe message\n ---\n duration_ms: 0.249084\n type: 'test'\n ...\n# Subtest: finds Jira project keys from common configured JQL scopes\nok 95 - finds Jira project keys from common configured JQL scopes\n ---\n duration_ms: 0.583364\n type: 'test'\n ...\n# Subtest: binds issue creation to the tracker reviewed in the modal\nok 96 - binds issue creation to the tracker reviewed in the modal\n ---\n duration_ms: 0.30702\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after successful issue creation\nok 97 - refreshes authoritative provider data after successful issue creation\n ---\n duration_ms: 0.171343\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after ambiguous issue creation failure\nok 98 - refreshes authoritative provider data after ambiguous issue creation failure\n ---\n duration_ms: 0.181962\n type: 'test'\n ...\n# Subtest: keeps structural headers on neutral host theme surfaces\nok 99 - keeps structural headers on neutral host theme surfaces\n ---\n duration_ms: 0.823341\n type: 'test'\n ...\n# Subtest: uses restrained state glyph, focus, and conversation treatments\nok 100 - uses restrained state glyph, focus, and conversation treatments\n ---\n duration_ms: 0.225582\n type: 'test'\n ...\n# Subtest: keeps constrained filter values inside the vertical menu measure\nok 101 - keeps constrained filter values inside the vertical menu measure\n ---\n duration_ms: 0.146748\n type: 'test'\n ...\n# Subtest: keeps assignee avatars compact with six theme-safe identity tones\nok 102 - keeps assignee avatars compact with six theme-safe identity tones\n ---\n duration_ms: 0.241631\n type: 'test'\n ...\n1..102\n# tests 102\n# suites 0\n# pass 102\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 335.126586\n\n> bb-plugin-taskboard@0.3.1 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n\n> bb-plugin-taskboard@0.3.1 verify:build\n> node scripts/verify-build.mjs\n\nTaskboard build metadata is valid.\n\n> bb-plugin-usage-tracker@0.1.2 check\n> npm run types:check && npm run typecheck && npm test && npm run build\n\n\n> bb-plugin-usage-tracker@0.1.2 types:check\n> env -u BB_CLI bb plugin types --check .\n\nThis plugin uses the npm package @get-bb/plugin-sdk; pin is 0.4.6, host is 0.4.6.\nThe declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.\n\n> bb-plugin-usage-tracker@0.1.2 typecheck\n> tsc --noEmit\n\n\n> bb-plugin-usage-tracker@0.1.2 test\n> node --test --experimental-strip-types test/*.test.ts\n\nTAP version 13\n# Subtest: enables sidebar providers independently in display order\nok 1 - enables sidebar providers independently in display order\n ---\n duration_ms: 1.039936\n type: 'test'\n ...\n# Subtest: normalizes compact limit preferences to the weekly default\nok 2 - normalizes compact limit preferences to the weekly default\n ---\n duration_ms: 0.130458\n type: 'test'\n ...\n# Subtest: normalizes providers in stable order with every usage window\nok 3 - normalizes providers in stable order with every usage window\n ---\n duration_ms: 0.563097\n type: 'test'\n ...\n# Subtest: normalizes not-installed and provider-error states\nok 4 - normalizes not-installed and provider-error states\n ---\n duration_ms: 0.201247\n type: 'test'\n ...\n# Subtest: clamps progress geometry and rejects non-finite values\nok 5 - clamps progress geometry and rejects non-finite values\n ---\n duration_ms: 0.922442\n type: 'test'\n ...\n# Subtest: formats reset, update, percentage, and cost copy safely\nok 6 - formats reset, update, percentage, and cost copy safely\n ---\n duration_ms: 11.857936\n type: 'test'\n ...\n# Subtest: selects the configured compact usage window\nok 7 - selects the configured compact usage window\n ---\n duration_ms: 0.484823\n type: 'test'\n ...\n# Subtest: falls back when the configured compact window is unavailable\nok 8 - falls back when the configured compact window is unavailable\n ---\n duration_ms: 0.202338\n type: 'test'\n ...\n# Subtest: keeps last-known sidebar windows through partial and failed refreshes\nok 9 - keeps last-known sidebar windows through partial and failed refreshes\n ---\n duration_ms: 0.387897\n type: 'test'\n ...\n# Subtest: resolves the thread environment host\nok 10 - resolves the thread environment host\n ---\n duration_ms: 0.363553\n type: 'test'\n ...\n# Subtest: falls back when a thread has no environment or lookup fails\nok 11 - falls back when a thread has no environment or lookup fails\n ---\n duration_ms: 0.243935\n type: 'test'\n ...\n# Subtest: loads usage for the resolved host and tolerates missing host metadata\nok 12 - loads usage for the resolved host and tolerates missing host metadata\n ---\n duration_ms: 0.267437\n type: 'test'\n ...\n# Subtest: omits host override for primary-machine fallback\nok 13 - omits host override for primary-machine fallback\n ---\n duration_ms: 0.195717\n type: 'test'\n ...\n# Subtest: loads the primary machine directly for the sidebar strip\nok 14 - loads the primary machine directly for the sidebar strip\n ---\n duration_ms: 0.114509\n type: 'test'\n ...\n# Subtest: propagates thread and request-level usage failures\nok 15 - propagates thread and request-level usage failures\n ---\n duration_ms: 0.217447\n type: 'test'\n ...\n1..15\n# tests 15\n# suites 0\n# pass 15\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 89.382837\n\n> bb-plugin-usage-tracker@0.1.2 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n", + "stderrTail": "", + "stdoutTruncated": false, + "stderrTruncated": false + } +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-d6bd9e2e6127d49da8a1d867.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-d6bd9e2e6127d49da8a1d867.json new file mode 100644 index 0000000..4bd30dc --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/receipts/executed-d6bd9e2e6127d49da8a1d867.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "id": "executed-d6bd9e2e6127d49da8a1d867", + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-4", + "AC-5", + "AC-6", + "AC-7", + "AC-8", + "AC-9" + ], + "evidenceKinds": [ + "test" + ], + "summary": "Run the final root check for the authorship-preserving PR #10/#11/#12 integration: Usage Tracker compact-limit behavior, Taskboard current preference regressions, bounded named preset schema/SQLite/RPC/CLI/UI guards, Git-only manifests, contributor docs, builds, and metadata.", + "passed": true, + "startedAt": "2026-08-27T02:36:29.025Z", + "completedAt": "2026-08-27T02:36:41.942Z", + "provenance": { + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "specRevision": 3, + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "treeDigest": "sha256:8b173f88e9f915d13babb74518770a8201a8d7efeb88719e0196dd32ab52bdd2", + "policyDigest": "sha256:07832222126a8e5297c6f9da9d153a26653bb8b3f3665dd024d61fc8ca82436e" + }, + "digest": "sha256:f305dc4b240f91cc72f87b5a8ae362b760d4328b7948e82b6b6ad9b93f4eb0e8", + "kind": "executed", + "command": { + "argv": [ + "npm", + "run", + "check" + ], + "cwd": ".", + "timeoutMs": 900000, + "maxOutputBytes": 1048576, + "environmentKeys": [ + "NO_COLOR", + "PATH" + ] + }, + "result": { + "exitCode": 0, + "signal": null, + "timedOut": false, + "stdoutDigest": "sha256:b4c2f777447ef528d9cec5ba16d4fdbb292976fe6fdefd62344606ba620969f2", + "stderrDigest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "stdoutTail": "s only exact preset order permutations\n ---\n duration_ms: 0.491085\n type: 'test'\n ...\n# Subtest: parses a tagged model response into a ticket draft\nok 81 - parses a tagged model response into a ticket draft\n ---\n duration_ms: 2.227501\n type: 'test'\n ...\n# Subtest: rejects malformed or incomplete model output\nok 82 - rejects malformed or incomplete model output\n ---\n duration_ms: 0.907645\n type: 'test'\n ...\n# Subtest: builds a read-only repository-aware worker prompt\nok 83 - builds a read-only repository-aware worker prompt\n ---\n duration_ms: 0.238425\n type: 'test'\n ...\n# Subtest: keeps the original prompt as an editable fallback\nok 84 - keeps the original prompt as an editable fallback\n ---\n duration_ms: 0.227505\n type: 'test'\n ...\n# Subtest: selects the active thread project only when it still exists\nok 85 - selects the active thread project only when it still exists\n ---\n duration_ms: 0.819614\n type: 'test'\n ...\n# Subtest: treats a different thread in the same project as new context\nok 86 - treats a different thread in the same project as new context\n ---\n duration_ms: 0.194474\n type: 'test'\n ...\n# Subtest: reads the project and thread from a BB project route\nok 87 - reads the project and thread from a BB project route\n ---\n duration_ms: 0.747071\n type: 'test'\n ...\n# Subtest: uses only the immediately previous navigation entry as source context\nok 88 - uses only the immediately previous navigation entry as source context\n ---\n duration_ms: 0.212597\n type: 'test'\n ...\n# Subtest: never replaces an explicit Taskboard project selection with route context\nok 89 - never replaces an explicit Taskboard project selection with route context\n ---\n duration_ms: 0.154862\n type: 'test'\n ...\n# Subtest: writes project credentials atomically with owner-only permissions\nok 90 - writes project credentials atomically with owner-only permissions\n ---\n duration_ms: 6.006248\n type: 'test'\n ...\n# Subtest: deleting a missing credential is harmless\nok 91 - deleting a missing credential is harmless\n ---\n duration_ms: 0.809626\n type: 'test'\n ...\n# Subtest: cached summaries never retain provider comments\nok 92 - cached summaries never retain provider comments\n ---\n duration_ms: 1.13006\n type: 'test'\n ...\n# Subtest: provider creation results carry strict native assignee confirmation\nok 93 - provider creation results carry strict native assignee confirmation\n ---\n duration_ms: 0.188193\n type: 'test'\n ...\n# Subtest: metadata failures expose only a fixed server-safe message\nok 94 - metadata failures expose only a fixed server-safe message\n ---\n duration_ms: 0.280422\n type: 'test'\n ...\n# Subtest: finds Jira project keys from common configured JQL scopes\nok 95 - finds Jira project keys from common configured JQL scopes\n ---\n duration_ms: 0.551626\n type: 'test'\n ...\n# Subtest: binds issue creation to the tracker reviewed in the modal\nok 96 - binds issue creation to the tracker reviewed in the modal\n ---\n duration_ms: 0.309725\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after successful issue creation\nok 97 - refreshes authoritative provider data after successful issue creation\n ---\n duration_ms: 0.171102\n type: 'test'\n ...\n# Subtest: refreshes authoritative provider data after ambiguous issue creation failure\nok 98 - refreshes authoritative provider data after ambiguous issue creation failure\n ---\n duration_ms: 0.184857\n type: 'test'\n ...\n# Subtest: keeps structural headers on neutral host theme surfaces\nok 99 - keeps structural headers on neutral host theme surfaces\n ---\n duration_ms: 0.811139\n type: 'test'\n ...\n# Subtest: uses restrained state glyph, focus, and conversation treatments\nok 100 - uses restrained state glyph, focus, and conversation treatments\n ---\n duration_ms: 0.2417\n type: 'test'\n ...\n# Subtest: keeps constrained filter values inside the vertical menu measure\nok 101 - keeps constrained filter values inside the vertical menu measure\n ---\n duration_ms: 0.145245\n type: 'test'\n ...\n# Subtest: keeps assignee avatars compact with six theme-safe identity tones\nok 102 - keeps assignee avatars compact with six theme-safe identity tones\n ---\n duration_ms: 0.26361\n type: 'test'\n ...\n1..102\n# tests 102\n# suites 0\n# pass 102\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 316.54756\n\n> bb-plugin-taskboard@0.3.1 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n\n> bb-plugin-taskboard@0.3.1 verify:build\n> node scripts/verify-build.mjs\n\nTaskboard build metadata is valid.\n\n> bb-plugin-usage-tracker@0.1.2 check\n> npm run types:check && npm run typecheck && npm test && npm run build\n\n\n> bb-plugin-usage-tracker@0.1.2 types:check\n> env -u BB_CLI bb plugin types --check .\n\nThis plugin uses the npm package @get-bb/plugin-sdk; pin is 0.4.6, host is 0.4.6.\nThe declarations are in node_modules/@get-bb/plugin-sdk/bundled-types/ — read them for exact signatures.\n\n> bb-plugin-usage-tracker@0.1.2 typecheck\n> tsc --noEmit\n\n\n> bb-plugin-usage-tracker@0.1.2 test\n> node --test --experimental-strip-types test/*.test.ts\n\nTAP version 13\n# Subtest: enables sidebar providers independently in display order\nok 1 - enables sidebar providers independently in display order\n ---\n duration_ms: 0.911221\n type: 'test'\n ...\n# Subtest: normalizes compact limit preferences to the weekly default\nok 2 - normalizes compact limit preferences to the weekly default\n ---\n duration_ms: 0.111843\n type: 'test'\n ...\n# Subtest: normalizes providers in stable order with every usage window\nok 3 - normalizes providers in stable order with every usage window\n ---\n duration_ms: 0.461241\n type: 'test'\n ...\n# Subtest: normalizes not-installed and provider-error states\nok 4 - normalizes not-installed and provider-error states\n ---\n duration_ms: 0.199694\n type: 'test'\n ...\n# Subtest: clamps progress geometry and rejects non-finite values\nok 5 - clamps progress geometry and rejects non-finite values\n ---\n duration_ms: 0.270133\n type: 'test'\n ...\n# Subtest: formats reset, update, percentage, and cost copy safely\nok 6 - formats reset, update, percentage, and cost copy safely\n ---\n duration_ms: 11.645158\n type: 'test'\n ...\n# Subtest: selects the configured compact usage window\nok 7 - selects the configured compact usage window\n ---\n duration_ms: 0.355498\n type: 'test'\n ...\n# Subtest: falls back when the configured compact window is unavailable\nok 8 - falls back when the configured compact window is unavailable\n ---\n duration_ms: 0.187773\n type: 'test'\n ...\n# Subtest: keeps last-known sidebar windows through partial and failed refreshes\nok 9 - keeps last-known sidebar windows through partial and failed refreshes\n ---\n duration_ms: 0.418503\n type: 'test'\n ...\n# Subtest: resolves the thread environment host\nok 10 - resolves the thread environment host\n ---\n duration_ms: 0.289969\n type: 'test'\n ...\n# Subtest: falls back when a thread has no environment or lookup fails\nok 11 - falls back when a thread has no environment or lookup fails\n ---\n duration_ms: 0.189705\n type: 'test'\n ...\n# Subtest: loads usage for the resolved host and tolerates missing host metadata\nok 12 - loads usage for the resolved host and tolerates missing host metadata\n ---\n duration_ms: 0.275733\n type: 'test'\n ...\n# Subtest: omits host override for primary-machine fallback\nok 13 - omits host override for primary-machine fallback\n ---\n duration_ms: 0.145856\n type: 'test'\n ...\n# Subtest: loads the primary machine directly for the sidebar strip\nok 14 - loads the primary machine directly for the sidebar strip\n ---\n duration_ms: 0.091998\n type: 'test'\n ...\n# Subtest: propagates thread and request-level usage failures\nok 15 - propagates thread and request-level usage failures\n ---\n duration_ms: 0.200826\n type: 'test'\n ...\n1..15\n# tests 15\n# suites 0\n# pass 15\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 85.076615\n\n> bb-plugin-usage-tracker@0.1.2 build\n> env -u BB_CLI bb plugin build .\n\ndist/server.js\ndist/server.js.map\ndist/server.meta.json\ndist/app.js\ndist/app.css\ndist/app.meta.json\n", + "stderrTail": "", + "stdoutTruncated": false, + "stderrTruncated": false + } +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/review-receipt-input.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/review-receipt-input.json new file mode 100644 index 0000000..0458815 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/review-receipt-input.json @@ -0,0 +1,20 @@ +{ + "criteria": ["AC-1", "AC-2", "AC-3", "AC-4", "AC-5", "AC-6", "AC-7", "AC-8", "AC-9"], + "evidenceKinds": ["review"], + "summary": "Independent final code, security, and UI/UX reviews covered every acceptance criterion, accepted decision, contributor head, conflict resolution, concurrent Host Monitor merge, and current final candidate; no blocking, high, or medium finding remains.", + "collector": "codex", + "artifacts": [ + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/code-review.md", + "mediaType": "text/markdown" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/security.md", + "mediaType": "text/markdown" + }, + { + "path": ".empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/consults/ui-ux.md", + "mediaType": "text/markdown" + } + ] +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/test-receipt-input.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/test-receipt-input.json new file mode 100644 index 0000000..261cfb8 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/test-receipt-input.json @@ -0,0 +1,6 @@ +{ + "commandId": "workspace-check", + "criteria": ["AC-1", "AC-2", "AC-3", "AC-4", "AC-5", "AC-6", "AC-7", "AC-8", "AC-9"], + "evidenceKinds": ["test"], + "summary": "Run the final root check for the authorship-preserving PR #10/#11/#12 integration: Usage Tracker compact-limit behavior, Taskboard current preference regressions, bounded named preset schema/SQLite/RPC/CLI/UI guards, Git-only manifests, contributor docs, builds, and metadata." +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/verification.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/verification.md new file mode 100644 index 0000000..5143aed --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/evidence/verification.md @@ -0,0 +1,106 @@ +# Community PR integration verification + +## Integration history and concurrent main + +- The owner integration branch was built from current main and preserves the + original contributor heads as ancestors: + - PR #10: `201c44a5f825c724fcfdfa6876437ceb2730814b` — Stephen Dolan. + - PR #11: `445e5ac4f3943a8eb4821f27a808715f46e57450` — Andrii Los. + - PR #12: `91037db6add4edd08c313025cb8a1e51896458ad` — Andrii Los. +- PR #10 was merged normally. PR #11 was recorded through a whole-tree `ours` + merge because its requested remembered-filter outcome is already shipped by + a safer and richer current architecture. PR #12 was merged afterward so its + seven unique preset commits remain authentic ancestors. +- Main advanced during the work with Host Monitor at `fc4a1b9`; merge commit + `9296d03` incorporated it. Host Monitor's complete plugin, lockfile, + collection, docs, notices, capability, and evidence remain present and its + full checks pass. +- Root and plugin READMEs visibly credit Stephen Dolan and Andrii Los with + linked GitHub identities. Original commit authors and the existing Claude + co-author trailer remain unchanged. + +## Usage Tracker compact limit + +- Weekly and Five-hour are validated select values; Weekly is the default for + missing, legacy, and unknown values. +- The compact percentage and progress rail use the selected fresh window, then + a fresh alternative, and only then last-known data. Expanded details retain + merged last-known continuity. +- Accessible text names the configured limit, actual selected window/value, and + whether a current-alternative or last-known fallback is displayed. +- Focused sequence/fallback/accessibility tests pass. Usage Tracker typecheck, + 17/17 tests, and build pass. + +## Taskboard named presets + +- PR #11's obsolete `filter-state.ts`, `work-schemas.ts`, + `project_filter_state`, and filter-state RPC writer remain absent. Current + versioned device-local project/provider and Across-project browse preferences + remain the sole automatic current-view store. +- Named presets are real-project-only and persist the complete strict current + `BrowsePreferences`: version, provider, source, query, List/Kanban view, all + facets, and collapse overrides. Apply requires the authoritative current + provider and atomically writes the full state through the existing observable + store; no preset auto-applies. +- SQLite migration is append-only and creates only + `project_filter_presets`. CRUD is project-scoped and transactional; IDs are + `NOT NULL`, raw/canonical metadata is checked, corrupt rows are isolated, + reads are bounded, names are NFKC/lowercase unique, reorder requires an exact + visible permutation, delete is idempotent, and provider check+save are one + project mutation critical section. +- Known-shape prevalidation rejects oversized containers before element walks. + Every schema-valid full view fits the 910,000-byte individual envelope + (measured adversarial maximum: 900,441 bytes), while the transactional + 950,000-byte project aggregate plus compact JSON/runtime guard keeps list and + save responses below BB's 1,048,576-byte CLI ceiling without duplicating a + saved full state in the response. +- CLI preset CRUD and `list --preset` pass explicit source/query precedence, + post-sync provider revalidation, enabled-board-facet parity with UI, bounded + item retrieval, and existing external-output escaping. +- Full/constrained preset menus, save dialog, provider errors, background + realtime/reconnect reconciliation, project-switch scope guards, controlled + Manage mutations, responsive rows, focus recovery, and apply/save feedback + are covered by source guards and independent UI review. + +## Checks and live runtime + +- Signed receipt `executed-13777e8d218261389af72cdf` records the final root + `npm run check` at tree digest + `sha256:322e4afd46bb1c33727872f8c76d07ca0bd57d4f2c59c52112171b2d3f5f8f6b`. +- Taskboard: SDK/typecheck, 109/109 tests, production build, and build metadata + verification pass. +- Usage Tracker: SDK/typecheck, 17/17 tests, and production build pass. +- Host Monitor: SDK/typecheck, complete test suite, and production build pass. +- `git diff --check` and conflict-marker scans pass. +- Live path Taskboard reload succeeds at v0.3.1 with running sync service and no + status error. `bb taskboard presets list --project proj_ykxahiys47 --json` + returns `{ "presets": [] }`, proving migration/RPC/CLI activation without + mutating user preset data. Existing GitHub sync warnings are caused by the + separately disabled official GitHub plugin, not this candidate. +- The live Usage Tracker source was not moved from the user's other local path; + its new compact behavior is verified through focused tests/typecheck/build + without changing that installation. + +## Independent review + +- Security verdict: advisory, no blocking or medium findings; three low + residuals are recorded. +- UI/UX verdict: advisory; all five initial findings were resolved. One low + retained-refresh retry focus advisory remains. +- Independent code review: advisory, no blocking/high/medium findings. + +## Acceptance coverage + +- AC-1: compact limit selection/default/fresh fallback/a11y pass. +- AC-2: released current preference architecture retained; obsolete writer + excluded. +- AC-3/AC-4: complete project presets, safe persistence/management, bounds and + corruption/provider isolation pass. +- AC-5: CLI CRUD/list precedence and effective-filter parity pass. +- AC-6: released Taskboard/Usage/distribution contracts and concurrent Host + Monitor remain compatible. +- AC-7: feature docs and linked contributors pass. +- AC-8: exact contributor heads are ancestors; remote PR status finalization is + a post-hosted-merge delivery check. +- AC-9: focused/root/build/live/review checks pass; Empirical integration and + hosted delivery remain next gates. diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/impact.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/impact.json new file mode 100644 index 0000000..41f0bcb --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/impact.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "classification": "behavioral", + "capabilities": [ + "project-view-preferences", + "usage-tracker-compact-display" + ], + "surfaces": [ + "workflow" + ], + "regressionRationale": null, + "digest": "sha256:f68b67f7fc667b720cf9433b7aa0069ae03a0c96706d220157b7a808f3b93430" +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/integration-receipt.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/integration-receipt.json new file mode 100644 index 0000000..db8a8b4 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/integration-receipt.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "feature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "claimId": "review-open-prs-10-11-and-12-in-mateocerquetella-bb-59becfd36252", + "repositoryId": "sha256:f8da5183fda8733511f6cba0809d0dd294fa59b7a90511fee567dc8aa4f04edc", + "baseCommit": "ce9a73401f7bd448f347f6dc6b9d7ae81565df6e", + "baseTree": "63313d945e988b90d25d283adc46fd318d7f9c75", + "featureTree": "sha256:686903948dd7d03d4a8bcddd9a1408517231bb58c86e671ac8b99e440d1e414f", + "targetCommit": "fc4a1b9d66f8b9707874663b08b754edcf1a1c64", + "targetTree": "bbc6cc0647bced9d00457395c9f0989fa2368799", + "capabilityBaseDigests": { + "project-view-preferences": "sha256:26ae4222a112c4d901d84d9f3b3094bffa78f06322d33e0af303fe487b20f745", + "usage-tracker-compact-display": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "deltaDigest": "sha256:2129106b75e7e37a7d629ae12cea23fd0fc67263d37da19f49c706fc401898e1", + "resultDigests": { + "project-view-preferences": "sha256:e47a1c8782f033fb494fb179321be1dbab0451f64b275771b9dc253e4c41446f", + "usage-tracker-compact-display": "sha256:d7c1e8200ad0603b715cf22cfbd998c799421c3a38faeecf8bb01a0ada0ca376" + }, + "verificationReceiptDigests": [ + "sha256:c9da166bc50bc8a3dbed52505973e2669af754f95f2ab96581e2545ed4291efc" + ], + "integratedAt": "2026-08-27T03:34:05.657Z", + "digest": "sha256:ef2c343bf435e5e5088f5f8ffae6b732ad0f1fbd85320dc9be5b92ed651b9b10" +} diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/plan.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/plan.md new file mode 100644 index 0000000..0fba416 --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/plan.md @@ -0,0 +1,39 @@ +# Plan: Integrate Community PRs 10, 11, and 12 + +## 1. Establish the integration history + +- Fetch current main and exact PR heads; create one owner integration branch. +- Merge PR #10 normally. +- Merge PR #11 with the whole-tree `ours` strategy and verify no obsolete + persistence files/symbols enter the tree. +- Merge PR #12 after #11 so only its seven preset commits are new, preserving + conflict markers for deliberate semantic resolution. + +## 2. Resolve Usage Tracker and Taskboard behavior + +- Retain PR #10 compact-limit preference, normalization, live selection, + fallback, docs, and tests against current Usage Tracker. +- Port preset schemas/storage/RPC/CLI/tests to strict `BrowsePreferences`. +- Integrate preset save/apply/manage controls into the current Taskboard UI and + observable store without restoring obsolete filter markup or writers. +- Preserve private Git-only manifests, versions, provider safety, query, + collapse, assignee, filter icons, and responsive behavior. + +## 3. Credit and verify + +- Add Stephen Dolan and Andrii Los to root/plugin contributor documentation. +- Add focused guards for obsolete PR #11 mechanics and author ancestry. +- Run focused suites, root `npm run check`, build metadata, CLI/RPC exercises, + and live Taskboard/Usage Tracker reload walkthroughs. +- Complete security and independent code review, then replay capability deltas + against detached current main. + +## 4. Deliver + +- Commit only the reviewed conflict resolution and Empirical integration state + atop the original contributor merge parents. +- Push the owner integration branch, open one source PR, require hosted CI, and + merge normally into main. +- Verify exact PR #10/#11/#12 head SHAs and contributor commits are ancestors of + main; confirm the original PRs show merged or close only with the integration + PR reference when GitHub cannot auto-detect. diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/spec.md b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/spec.md new file mode 100644 index 0000000..680968b --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/spec.md @@ -0,0 +1,90 @@ +# Integrate Community PRs 10, 11, and 12 + +## Request + +> Review open PRs #10, #11, and #12 in MateoCerquetella/bb-plugins; resolve their merge conflicts against current main without regressing the released Taskboard and Usage Tracker behavior; preserve original contributor authorship, add the contributors to repository credits, run the complete checks, and prepare all safe PRs for merge into main. + +## Goal + +Integrate the useful community work from all three PRs onto current main with +one coherent preference architecture, passing checks, visible contributor +credit, and Git history that retains the original authors. + +## Acceptance Criteria + +- [ ] [AC-1] Usage Tracker exposes a validated **Compact limit** setting with + Weekly and Five-hour choices, defaults legacy/unknown values to Weekly, + updates the compact percentage/bar live, and falls back to the other reported + window when the preferred window is absent; expanded details still show both. +- [ ] [AC-2] Taskboard retains the released versioned, project/provider-scoped, + device-local browse preference store shared by full and right-panel surfaces, + including query, filters, view, and collapse state. Conflict resolution SHALL + not introduce a second automatic server-side filter-state writer, stale RPC + race, or schema that discards released preference fields. +- [ ] [AC-3] Taskboard supports named filter presets scoped to one BB project: + save the complete current browse state, apply it explicitly from the filter + bar, and rename/reorder/delete it in Manage. Applying a preset replaces all + preset fields through the existing preference store and never auto-applies a + default preset. +- [ ] [AC-4] Presets are stored in Taskboard's plugin database with bounded, + locale-independent case-insensitive names, deterministic ordering, strict + state validation, project isolation, duplicate rejection, corrupt-row + containment, permutation-checked reorder, and idempotent deletion. +- [ ] [AC-5] The Taskboard CLI can list/save/rename/delete presets and use + `list --preset`; explicit `--source` and `--query` override preset values, + while remaining preset facets use the same filtering logic as the UI. +- [ ] [AC-6] Existing Taskboard creation, provider, browsing, remembered query, + assignee, filters, List/Kanban, and Usage Tracker refresh/error behavior remain + compatible; no package version, Git-only distribution, credential, or release + contract regresses. +- [ ] [AC-7] Root and plugin documentation describe both new capabilities, and + a Contributors section credits [Stephen Dolan](https://github.com/stephendolan) + and [Andrii Los](https://github.com/RIP21). +- [ ] [AC-8] Original PR commits/authors remain ancestors of the delivered + integration or equivalent commits carry explicit `Co-authored-by` credit; + PRs #10, #11, and #12 end merged or are closed only with a precise superseded- + by-merged-integration explanation. +- [ ] [AC-9] Focused tests, root `npm run check`, build metadata, live plugin + reload checks, independent review, and Empirical integration all pass before + remote merge. + +## Scope + +- PR #10 Usage Tracker compact-limit preference. +- PR #11 remembered-filter contribution and its conflicts with the released + browse preference architecture. +- PR #12 named filter preset module, database, RPC, UI, Manage, CLI, tests, and + documentation. +- Repository contributor credits and authorship-preserving Git integration. + +## Non-goals + +- Replacing released device-local browse preferences with an incompatible + server-only automatic filter-state mechanism. +- Automatically applying a default preset or tracking a dirty/active preset. +- Adding provider-specific `@me` identity resolution. +- Publishing new plugin versions, tags, npm packages, or marketplace changes. +- Merging code that fails current contracts merely to make an old PR green. + +## Risks + +- PR #12 is stacked on #11 and both predate major Taskboard preference/UI work; + naive merging can duplicate persistence, lose query/collapse state, or revive + old bundle/package assumptions. +- Preset state crosses UI, RPC, SQLite, and CLI trust boundaries; unbounded or + loosely parsed state can create resource or cross-project integrity failures. +- Squashing or recreating contributor work can erase GitHub attribution. + +## Verification + +- Compare each PR commit/file set with current main and record conflict choices. +- Unit tests for compact-limit selection/fallback and preset schema/name/order/ + project/state behavior, plus existing suites and root `npm run check`. +- CLI/RPC checks and a live Taskboard/Usage Tracker reload walkthrough. +- Git ancestry/author audit and README contributor-link assertions. +- Independent security/code review and Empirical detached integration replay. + +## Capability Deltas + +- `deltas/usage-tracker-compact-display.md` +- `deltas/project-view-preferences.md` diff --git a/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/state.json b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/state.json new file mode 100644 index 0000000..08c1ced --- /dev/null +++ b/.empirical/specs/review-open-prs-10-11-and-12-in-mateocerquetella-bb/state.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 5, + "revision": 10, + "activeFeature": "review-open-prs-10-11-and-12-in-mateocerquetella-bb", + "request": "Review open PRs #10, #11, and #12 in MateoCerquetella/bb-plugins; resolve their merge conflicts against current main without regressing the released Taskboard and Usage Tracker behavior; preserve original contributor authorship, add the contributors to repository credits, run the complete checks, and prepare all safe PRs for merge into main.", + "profile": "complex", + "workflow": "complex", + "mode": "yolo", + "phase": "done", + "status": "done", + "repairAttempts": 0, + "message": "Integrated capability changes: project-view-preferences, usage-tracker-compact-display", + "implementationActor": "agent", + "specDigest": "sha256:88548af21f8df53c21c6214722c4d7612241c0c43b17ee755dd381a1e15fa447", + "approvedSpecRevision": 3, + "capabilityArchiveRequired": true, + "capabilityDeltaDigest": "sha256:1e403bb79f025c25027652bd89253ec16aa00d6f825a26d6b0e96fc55381e1aa", + "impactDigest": "sha256:f68b67f7fc667b720cf9433b7aa0069ae03a0c96706d220157b7a808f3b93430", + "capabilityClaimId": "review-open-prs-10-11-and-12-in-mateocerquetella-bb-59becfd36252", + "authorizationDigest": "sha256:8e131cdb10ceb4f25b9b720581e879a03c6bbaf5b3ae32c40a47ade191d29a19", + "evidence": [], + "evidenceReceiptIds": [ + "executed-13777e8d218261389af72cdf", + "collected-71b3cbfb605f5596ddbf3ae4", + "collected-c1ed7405d4b6b355f5f140e3" + ], + "legacyEvidenceCount": 0, + "completion": { + "implemented": true, + "verified": true, + "integrated": true, + "delivered": false, + "published": false, + "highest": "integrated", + "reasons": { + "delivered": "delivered has not been proven by a durable receipt.", + "published": "published has not been proven by a durable receipt." + } + }, + "updatedAt": "2026-08-27T03:34:05.677Z" +} diff --git a/README.md b/README.md index 4ea3b49..5596d48 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,10 @@ external tracker for it. Different BB projects can use different providers. Taskboard keeps rows and Kanban cards compact, preserves each provider's real workflow, opens live issue details, and can send any task to an agent with its -context attached. See the [Taskboard README](./plugins/taskboard) for GitHub, -Linear, Jira, CLI, and credential setup. +context attached. Each project's remembered view can also be saved as a named +preset and reapplied explicitly from the board or CLI. See the +[Taskboard README](./plugins/taskboard) for GitHub, Linear, Jira, presets, CLI, +and credential setup. Update or remove it with BB: @@ -108,7 +110,8 @@ Usage Tracker mounts in BB's native sidebar footer beside the existing utility icons. Each provider gets a compact progress bar and current usage reading. Select Codex or Claude Code to expand its five-hour and weekly limits, reset times, and session status without leaving the current thread. There is no -separate plugin page to manage. +separate plugin page to manage. The **Compact limit** setting chooses whether +the collapsed percentage and bar prefer weekly or five-hour usage. The strip refreshes automatically every five minutes, refreshes when a stale BB window becomes active again, and includes a manual refresh control. If a @@ -177,6 +180,14 @@ New plugins belong in `plugins/` with their own `package.json`, source, tests, pinned `@get-bb/plugin-sdk` development dependency, and README. Add each directory to `.bb/plugins.json`; the root workspace picks it up automatically. +## Contributors + +- [Stephen Dolan (@stephendolan)](https://github.com/stephendolan) contributed + Usage Tracker's configurable Compact limit. +- [Andrii Los (@RIP21)](https://github.com/RIP21) contributed Taskboard's + project-view persistence work and dogfooding fixes, plus named filter + presets. + ## License [MIT](./LICENSE) © 2026 Mateo Cerquetella. diff --git a/plugins/taskboard/README.md b/plugins/taskboard/README.md index cc5de03..3476fa5 100644 --- a/plugins/taskboard/README.md +++ b/plugins/taskboard/README.md @@ -37,6 +37,11 @@ task to an agent without rebuilding context by hand. the default List or Kanban layout, and the exact provider status order. Provider-native workflow groups, drag-and-drop, and keyboard status moves remain available without repetitive provider chips on every task. +- **Filter presets** — save the complete current project view, including its + search, filters, List or Kanban mode, and collapsed groups, then explicitly + reapply it from the **Presets** menu. Rename, reorder, and delete presets in + **Manage → Board preferences**. Presets stay scoped to one project, are not + available in Across projects, and never apply automatically. - **Live task details** — cached summaries keep browsing fast; opening a task fetches its current description, labels, assignee, and comments. - **Pinned beside every chat** — open Taskboard from the thread-header button, @@ -57,6 +62,9 @@ task to an agent without rebuilding context by hand. `bb taskboard`. Issue creation remains an intentional review-and-confirm UI action in the board or composer. +[Andrii Los (@RIP21)](https://github.com/RIP21) contributed Taskboard's +project-view persistence work and dogfooding fixes, plus named filter presets. + ## Install Taskboard is a full-trust BB plugin. Review the source, then install its tracking @@ -172,12 +180,21 @@ bb taskboard status [--project ] [--json] bb taskboard config [--project ] [--source linear|github|jira] [provider fields] [--json] bb taskboard credentials [--project ] [--json] bb taskboard refresh [linear|github|jira] [--project ] [--json] -bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json] +bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--preset ] [--cached] [--json] bb taskboard show [--project ] [--json] bb taskboard transitions [--project ] [--json] bb taskboard move --status [--project ] [--json] +bb taskboard presets list [--project ] [--json] +bb taskboard presets save --from-state [--project ] [--json] +bb taskboard presets rename [--project ] [--json] +bb taskboard presets delete [--project ] [--json] ``` +`presets save --from-state` accepts a complete versioned project-view JSON +object. `list --preset` resolves the preset name case-insensitively and applies +all of its facets through the same filtering rules as the board; explicit +`--source` and `--query` flags take precedence over the preset values. + An explicit source must match the tracker selected for that project. Taskboard rejects mismatches before contacting a provider. diff --git a/plugins/taskboard/app.tsx b/plugins/taskboard/app.tsx index b9d8301..e33983c 100644 --- a/plugins/taskboard/app.tsx +++ b/plugins/taskboard/app.tsx @@ -84,6 +84,8 @@ import type { } from './contract.js'; import { CREATE_OUTCOME_UNCERTAIN_MARKER, + FILTER_PRESET_NAME_MAX_LENGTH, + type FilterPreset, formatWorkItemHandoffPrompt } from './contract.js'; import { @@ -1617,6 +1619,128 @@ function useRefreshOnReconnect(refresh: () => void): void { }, [connectionState]); } +function useProjectFilterPresets(projectId: string | null): { + presets: readonly FilterPreset[]; + error: string | null; + refreshError: string | null; + loading: boolean; + reload: (options?: { background?: boolean }) => Promise; + setAuthoritative: (presets: readonly FilterPreset[]) => void; +} { + const rpc = useRpc(); + const [presets, setPresets] = useState([]); + const [error, setError] = useState(null); + const [refreshError, setRefreshError] = useState(null); + const [loading, setLoading] = useState(projectId !== null); + const [loadedProjectId, setLoadedProjectId] = useState( + projectId + ); + const requestRevisionRef = useRef(0); + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + const reload = useCallback(async ( + options: { background?: boolean } = {} + ) => { + if (projectIdRef.current !== projectId) return; + const requestRevision = ++requestRevisionRef.current; + if (projectId === null) { + setPresets([]); + setError(null); + setRefreshError(null); + setLoading(false); + setLoadedProjectId(null); + return; + } + if (!options.background) { + setPresets([]); + setLoading(true); + setLoadedProjectId(projectId); + setError(null); + } + setRefreshError(null); + try { + const result = await rpc.call('listFilterPresets', { projectId }); + if ( + requestRevision !== requestRevisionRef.current || + projectIdRef.current !== projectId + ) { + return; + } + setPresets(result.presets); + setError(null); + setRefreshError(null); + setLoadedProjectId(projectId); + } catch (nextError) { + if ( + requestRevision !== requestRevisionRef.current || + projectIdRef.current !== projectId + ) { + return; + } + const message = describeError(nextError); + if (options.background) { + setRefreshError(message); + } else { + setPresets([]); + setError(message); + } + setLoadedProjectId(projectId); + } finally { + if ( + requestRevision === requestRevisionRef.current && + projectIdRef.current === projectId + ) { + setLoading(false); + } + } + }, [projectId, rpc]); + + useEffect(() => { + void reload(); + return () => { + requestRevisionRef.current += 1; + }; + }, [reload]); + useRealtime('taskboard:presets-changed', payload => { + if (projectId === null) return; + const changedProject = changedProjectId(payload); + if (changedProject === null || changedProject === projectId) { + void reload({ background: true }); + } + }); + useRefreshOnReconnect(() => { + if (projectId !== null) void reload({ background: true }); + }); + + const setAuthoritative = useCallback( + (nextPresets: readonly FilterPreset[]) => { + if (projectIdRef.current !== projectId) return; + requestRevisionRef.current += 1; + setPresets(nextPresets); + setError(null); + setRefreshError(null); + setLoading(false); + setLoadedProjectId(projectId); + // A mutation result is authoritative for that request, but another + // surface may have committed a later change while it was in flight. + // Refresh after the response so the last completed read always wins. + void reload({ background: true }); + }, + [projectId, reload] + ); + + const scopeMatches = loadedProjectId === projectId; + return { + presets: scopeMatches ? presets : [], + error: scopeMatches ? error : null, + refreshError: scopeMatches ? refreshError : null, + loading: scopeMatches ? loading : projectId !== null, + reload, + setAuthoritative + }; +} + function loadRightPanelPinned(): boolean { try { return ( @@ -2293,7 +2417,144 @@ function TrackerViewToggle({ ); } +function FilterPresetMenu({ + presets, + error, + refreshError, + loading, + actionsReady, + constrained = false, + onApply, + onRetry, + onSaveCurrent +}: { + presets: readonly FilterPreset[]; + error: string | null; + refreshError: string | null; + loading: boolean; + actionsReady: boolean; + constrained?: boolean; + onApply: (preset: FilterPreset) => void; + onRetry: () => void; + onSaveCurrent: () => void; +}) { + const hasLoadIssue = error !== null || refreshError !== null; + return ( + + + {constrained ? ( + + ) : ( + + )} + + +
+ {!actionsReady ? ( + + Waiting for the project tracker… + + ) : null} + {loading ? ( + Loading presets… + ) : error ? ( + <> +
+ Could not load presets: {error} +
+ + + Try again + + + ) : presets.length === 0 ? ( + No saved presets + ) : ( + presets.map(preset => ( + onApply(preset)} + > + + )) + )} + {refreshError && !error ? ( +
+ Could not refresh presets. Keeping the last loaded list. + +
+ ) : null} +
+
+ + +
+
+
+ ); +} + function TrackerFilterBar({ + presets, + presetsError, + presetsRefreshError, + presetsLoading, + presetActionsReady, + onApplyPreset, + onRetryPresets, + onSaveCurrentPreset, source, enabledFilters, stateCategories, @@ -2323,6 +2584,14 @@ function TrackerFilterBar({ onViewChange, onClear }: { + presets: readonly FilterPreset[] | null; + presetsError: string | null; + presetsRefreshError: string | null; + presetsLoading: boolean; + presetActionsReady: boolean; + onApplyPreset: (preset: FilterPreset) => void; + onRetryPresets: () => void; + onSaveCurrentPreset: () => void; source: SourceFilter; enabledFilters: readonly WorkItemFilterField[]; stateCategories: readonly WorkStateCategory[]; @@ -2435,6 +2704,19 @@ function TrackerFilterBar({ ) : ( )} + {presets !== null ? ( + + ) : null} { if (!open) setFacetQuery(''); @@ -2696,6 +2978,18 @@ function TrackerFilterBar({ )} >
+ {presets !== null ? ( + + ) : null} {showSourceFilter ? ( (null); + const [presetSaveError, setPresetSaveError] = useState(null); + const [savingPreset, setSavingPreset] = useState(false); + const savingPresetRef = useRef(false); + const presetSaveDescriptionId = useId(); + const presetSaveErrorId = useId(); + const [authoritativeProvider, setAuthoritativeProvider] = + useState(null); const [committedQuery, setCommittedQuery] = useState(() => query.trim()); const [error, setError] = useState(null); const requestRevisionRef = useRef(0); @@ -4008,6 +4311,7 @@ function TrackerList({ const loadItems = useCallback(async () => { if (!boardSettingsReady) return; const requestRevision = ++requestRevisionRef.current; + if (projectId !== null) setAuthoritativeProvider(null); setError(null); try { const result = await rpc.call('listItems', { @@ -4027,6 +4331,7 @@ function TrackerList({ provider, { view: boardSettings.defaultView } ); + setAuthoritativeProvider(provider); } setItems(result.items); } catch (nextError) { @@ -4234,6 +4539,63 @@ function TrackerList({ }, [updatePreferences] ); + const applyPreset = useCallback( + (preset: FilterPreset) => { + if (projectId === null || preset.projectId !== projectId) { + toast.error('This preset belongs to a different project.'); + return; + } + if (authoritativeProvider === null) { + toast.error('Wait for this project’s tracker to finish loading.'); + return; + } + if (preset.state.provider !== authoritativeProvider) { + toast.error( + 'This preset was saved for a different tracker. Save a new preset for the current project connection.' + ); + return; + } + browsePreferenceStore.set(preferenceScope, preset.state); + toast.success(`Applied preset "${preset.name}"`); + }, + [authoritativeProvider, preferenceScope, projectId] + ); + const saveCurrentPreset = useCallback( + async (name: string) => { + if (projectId === null || savingPresetRef.current) return; + if ( + authoritativeProvider === null || + preferences.provider !== authoritativeProvider + ) { + setPresetSaveError( + 'Wait for this project’s tracker to finish loading, then try again.' + ); + return; + } + savingPresetRef.current = true; + setSavingPreset(true); + setPresetSaveError(null); + try { + const result = await rpc.call('saveFilterPreset', { + projectId, + name, + state: preferences + }); + presetState.setAuthoritative(result.presets); + setPresetNameDraft(null); + setPresetSaveError(null); + toast.success(`Saved preset "${result.preset.name}"`); + } catch (nextError) { + const message = describeError(nextError); + setPresetSaveError(message); + toast.error(message); + } finally { + savingPresetRef.current = false; + setSavingPreset(false); + } + }, + [authoritativeProvider, preferences, presetState, projectId, rpc] + ); const moveItemStatus = useCallback( async (item: WorkItem, option: WorkStatusOption) => { const matches = (candidate: WorkItem) => @@ -4277,6 +4639,21 @@ function TrackerList({
+ void presetState.reload({ + background: presetState.presets.length > 0 + }) + } + onSaveCurrentPreset={() => { + setPresetSaveError(null); + setPresetNameDraft(''); + }} source={projectId === null ? source : ALL_SOURCES} enabledFilters={boardSettings.enabledFilters} stateCategories={stateCategories} @@ -4449,7 +4826,87 @@ function TrackerList({
); - return {content}; + return ( + + {content} + { + if (!open && !savingPreset) { + setPresetNameDraft(null); + setPresetSaveError(null); + } + }} + > + + + Save filter preset + + Save the current filters, search, layout, and collapsed groups + for this project. + + +
{ + event.preventDefault(); + const name = (presetNameDraft ?? '').trim(); + if (name) void saveCurrentPreset(name); + }} + className="flex flex-col gap-3" + > + setPresetNameDraft(event.target.value)} + placeholder="My work" + maxLength={FILTER_PRESET_NAME_MAX_LENGTH} + aria-label="Preset name" + aria-invalid={presetSaveError !== null} + aria-describedby={ + presetSaveError + ? `${presetSaveDescriptionId} ${presetSaveErrorId}` + : presetSaveDescriptionId + } + /> + {presetSaveError ? ( + + ) : null} +
+ + +
+ {savingPreset ? ( +

+ Saving filter preset +

+ ) : null} +
+
+
+
+ ); } function DetailMetadata({ @@ -5451,6 +5908,409 @@ function ProjectBoardSettingsForm({ ); } +function FilterPresetsForm({ projectId }: { projectId: string }) { + const rpc = useRpc(); + const presetState = useProjectFilterPresets(projectId); + const [nameDrafts, setNameDrafts] = useState>({}); + const [mutating, setMutating] = useState(false); + const [mutationFeedback, setMutationFeedback] = useState<{ + kind: 'error' | 'status'; + message: string; + presetId?: string; + } | null>(null); + const mutationInFlightRef = useRef(false); + const authoritativeNamesRef = useRef(new Map()); + const presetNameInputRefs = useRef(new Map()); + const presetActionButtonRefs = useRef(new Map()); + const headingRef = useRef(null); + const mutationFeedbackId = useId(); + + const restorePresetFocus = ( + presetId: string, + action?: 'move-up' | 'move-down' | 'delete' + ) => { + window.requestAnimationFrame(() => { + const actionButton = action + ? presetActionButtonRefs.current.get(`${presetId}:${action}`) + : undefined; + if (actionButton && !actionButton.disabled) { + actionButton.focus(); + return; + } + presetNameInputRefs.current.get(presetId)?.focus(); + }); + }; + + useEffect(() => { + const previousNames = authoritativeNamesRef.current; + const nextNames = new Map( + presetState.presets.map(preset => [preset.id, preset.name]) + ); + setNameDrafts(current => + Object.fromEntries( + presetState.presets.map(preset => { + const previousName = previousNames.get(preset.id); + const currentDraft = current[preset.id]; + const dirty = + previousName !== undefined && + currentDraft !== undefined && + currentDraft !== previousName; + return [preset.id, dirty ? currentDraft : preset.name]; + }) + ) + ); + authoritativeNamesRef.current = nextNames; + }, [presetState.presets]); + + const beginMutation = () => { + if (mutationInFlightRef.current) return false; + mutationInFlightRef.current = true; + setMutating(true); + setMutationFeedback(null); + return true; + }; + const finishMutation = () => { + mutationInFlightRef.current = false; + setMutating(false); + }; + const reportMutationError = ( + nextError: unknown, + options: { presetId?: string; action: string } + ) => { + const message = describeError(nextError); + setMutationFeedback({ + kind: 'error', + message: `${options.action}: ${message}`, + ...(options.presetId ? { presetId: options.presetId } : {}) + }); + toast.error(message); + }; + + const renamePreset = async (preset: FilterPreset) => { + const name = (nameDrafts[preset.id] ?? preset.name).trim(); + if (name === preset.name) { + setNameDrafts(current => ({ ...current, [preset.id]: preset.name })); + return; + } + if (!name) { + setMutationFeedback({ + kind: 'error', + presetId: preset.id, + message: `Rename "${preset.name}": preset names cannot be empty.` + }); + return; + } + if (!beginMutation()) return; + try { + const result = await rpc.call('saveFilterPreset', { + projectId, + id: preset.id, + name, + state: preset.state + }); + presetState.setAuthoritative(result.presets); + setMutationFeedback({ + kind: 'status', + message: `Renamed preset to "${result.preset.name}".` + }); + restorePresetFocus(preset.id); + } catch (nextError) { + reportMutationError(nextError, { + presetId: preset.id, + action: `Could not rename "${preset.name}"` + }); + restorePresetFocus(preset.id); + } finally { + finishMutation(); + } + }; + + const removePreset = async (preset: FilterPreset) => { + if (mutationInFlightRef.current) return; + if (!window.confirm(`Delete the preset "${preset.name}"?`)) return; + if (!beginMutation()) return; + const deletedIndex = presetState.presets.findIndex( + candidate => candidate.id === preset.id + ); + try { + const result = await rpc.call('deleteFilterPreset', { + projectId, + id: preset.id + }); + presetState.setAuthoritative(result.presets); + setMutationFeedback({ + kind: 'status', + message: `Deleted preset "${preset.name}".` + }); + const focusTarget = + result.presets[ + Math.min(Math.max(deletedIndex, 0), result.presets.length - 1) + ]; + window.requestAnimationFrame(() => { + const input = focusTarget + ? presetNameInputRefs.current.get(focusTarget.id) + : undefined; + (input ?? headingRef.current)?.focus(); + }); + } catch (nextError) { + reportMutationError(nextError, { + action: `Could not delete "${preset.name}"` + }); + restorePresetFocus(preset.id, 'delete'); + } finally { + finishMutation(); + } + }; + + const movePreset = async (preset: FilterPreset, delta: number) => { + if (mutationInFlightRef.current) return; + const ids = presetState.presets.map(candidate => candidate.id); + const from = ids.indexOf(preset.id); + const to = from + delta; + if (from < 0 || to < 0 || to >= ids.length || !beginMutation()) return; + const reordered = [...ids]; + const [moved] = reordered.splice(from, 1); + if (!moved) { + finishMutation(); + return; + } + reordered.splice(to, 0, moved); + try { + const result = await rpc.call('reorderFilterPresets', { + projectId, + ids: reordered + }); + presetState.setAuthoritative(result.presets); + setMutationFeedback({ + kind: 'status', + message: `Moved preset "${preset.name}" ${delta < 0 ? 'up' : 'down'}.` + }); + restorePresetFocus(preset.id, delta < 0 ? 'move-up' : 'move-down'); + } catch (nextError) { + reportMutationError(nextError, { + action: `Could not move "${preset.name}"` + }); + restorePresetFocus(preset.id, delta < 0 ? 'move-up' : 'move-down'); + } finally { + finishMutation(); + } + }; + + return ( +
+
+

+ Filter presets +

+

+ Rename, reorder, or delete this project's saved views. +

+
+ {presetState.loading ? ( +
+ Loading filter presets + + +
+ ) : presetState.error ? ( +
+

+ Could not load presets: {presetState.error} +

+ +
+ ) : presetState.presets.length === 0 ? ( +

+ Save a preset from the Presets menu on this project's board. +

+ ) : ( +
    + {presetState.presets.map((preset, index) => ( +
  • + { + if (element) { + presetNameInputRefs.current.set(preset.id, element); + } else { + presetNameInputRefs.current.delete(preset.id); + } + }} + value={nameDrafts[preset.id] ?? preset.name} + maxLength={FILTER_PRESET_NAME_MAX_LENGTH} + disabled={mutating} + aria-label={`Preset name for ${preset.name}`} + aria-invalid={ + mutationFeedback?.kind === 'error' && + mutationFeedback.presetId === preset.id + } + aria-describedby={ + mutationFeedback?.kind === 'error' && + mutationFeedback.presetId === preset.id + ? mutationFeedbackId + : undefined + } + className="h-8 min-w-0 w-full text-xs max-md:pointer-coarse:h-10 @sm:flex-1" + onChange={event => { + const name = event.target.value; + setNameDrafts(current => ({ + ...current, + [preset.id]: name + })); + if ( + mutationFeedback?.kind === 'error' && + mutationFeedback.presetId === preset.id + ) { + setMutationFeedback(null); + } + }} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + void renamePreset(preset); + } else if (event.key === 'Escape') { + event.preventDefault(); + setNameDrafts(current => ({ + ...current, + [preset.id]: preset.name + })); + if (mutationFeedback?.presetId === preset.id) { + setMutationFeedback(null); + } + } + }} + /> +
    + + + + +
    +
  • + ))} +
+ )} + {presetState.refreshError && !presetState.error ? ( +
+

+ Could not refresh presets. Keeping your loaded presets and edits. +

+ +
+ ) : null} + {mutationFeedback?.kind === 'error' ? ( + + ) : mutating ? ( +

+ Updating presets… +

+ ) : mutationFeedback?.kind === 'status' ? ( +

+ {mutationFeedback.message} +

+ ) : null} +
+ ); +} + function ManageView({ projectId, projects, @@ -5581,6 +6441,10 @@ function ManageView({ return result.settings; }} /> + ) : (
diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index d54ce92..b8c8dc4 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -8,6 +8,16 @@ import { secretMutationSchema } from './credential-contract.js'; import { projectBoardSettingsSchema } from './board-settings.js'; +import { + FILTER_PRESET_LIMIT, + filterPresetIdSchema, + filterPresetNameSchema, + filterPresetOrderSchema, + filterPresetProjectIdSchema, + filterPresetSchema, + filterPresetSummarySchema, + filterPresetStateSchema +} from './filter-presets.js'; export { DEFAULT_WORK_ITEM_FILTER_FIELDS, DEFAULT_WORKFLOW_STATUS_ORDER, @@ -21,6 +31,30 @@ export type { TrackerView, WorkItemFilterField } from './board-settings.js'; +export { + FILTER_PRESET_ID_MAX_LENGTH, + FILTER_PRESET_LIMIT, + FILTER_PRESET_NAME_MAX_LENGTH, + FILTER_PRESET_NORMALIZED_NAME_MAX_LENGTH, + FILTER_PRESET_PROJECT_STATE_BYTES_MAX, + FILTER_PRESET_PROJECT_ID_MAX_LENGTH, + FILTER_PRESET_STATE_JSON_MAX_LENGTH, + filterPresetIdSchema, + filterPresetNameSchema, + filterPresetOrderSchema, + filterPresetProjectIdSchema, + filterPresetSchema, + filterPresetSummary, + filterPresetSummarySchema, + filterPresetStateSchema, + normalizePresetName, + resolvePresetOrder, + serializeFilterPresetState +} from './filter-presets.js'; +export type { + FilterPreset, + FilterPresetSummary +} from './filter-presets.js'; export { bbProjectIdSchema, jiraBaseUrlSchema, @@ -446,6 +480,56 @@ export const taskboardRpcContract = defineRpcContract({ saveProjectBoardSettings: { input: projectBoardSettingsSchema, output: z.object({ settings: projectBoardSettingsSchema }).strict() + }, + listFilterPresets: { + input: z.object({ projectId: filterPresetProjectIdSchema }).strict(), + output: z + .object({ + presets: z.array(filterPresetSchema).max(FILTER_PRESET_LIMIT) + }) + .strict() + }, + saveFilterPreset: { + input: z + .object({ + projectId: filterPresetProjectIdSchema, + id: filterPresetIdSchema.optional(), + name: filterPresetNameSchema, + state: filterPresetStateSchema + }) + .strict(), + output: z + .object({ + preset: filterPresetSummarySchema, + presets: z.array(filterPresetSchema).max(FILTER_PRESET_LIMIT) + }) + .strict() + }, + deleteFilterPreset: { + input: z + .object({ + projectId: filterPresetProjectIdSchema, + id: filterPresetIdSchema + }) + .strict(), + output: z + .object({ + presets: z.array(filterPresetSchema).max(FILTER_PRESET_LIMIT) + }) + .strict() + }, + reorderFilterPresets: { + input: z + .object({ + projectId: filterPresetProjectIdSchema, + ids: filterPresetOrderSchema + }) + .strict(), + output: z + .object({ + presets: z.array(filterPresetSchema).max(FILTER_PRESET_LIMIT) + }) + .strict() } }); diff --git a/plugins/taskboard/filter-presets.ts b/plugins/taskboard/filter-presets.ts new file mode 100644 index 0000000..0cb948c --- /dev/null +++ b/plugins/taskboard/filter-presets.ts @@ -0,0 +1,426 @@ +import { z } from 'zod'; +import { + browsePreferencesV1Schema, + type BrowsePreferences +} from './browse-preferences.ts'; + +export const FILTER_PRESET_NAME_MAX_LENGTH = 60; +export const FILTER_PRESET_NORMALIZED_NAME_MAX_LENGTH = 240; +export const FILTER_PRESET_ID_MAX_LENGTH = 100; +export const FILTER_PRESET_PROJECT_ID_MAX_LENGTH = 500; +export const FILTER_PRESET_LIMIT = 50; +export const FILTER_PRESET_STATE_JSON_MAX_LENGTH = 910_000; +export const FILTER_PRESET_PROJECT_STATE_BYTES_MAX = 950_000; + +const FILTER_PRESET_VALUE_LIMIT = 100; +const FILTER_PRESET_COLLAPSED_GROUP_LIMIT = 100; +const FILTER_PRESET_VALUE_MAX_LENGTH = 500; +const FILTER_PRESET_QUERY_MAX_LENGTH = 500; +const FILTER_PRESET_STATE_CATEGORY_LIMIT = 5; +const FILTER_PRESET_TOP_LEVEL_KEYS = new Set([ + 'version', + 'provider', + 'source', + 'view', + 'query', + 'stateCategories', + 'statuses', + 'assignees', + 'priorities', + 'externalProjects', + 'labels', + 'collapsedGroups' +]); + +const unsafeControlCharacters = + /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; + +function hasUnsafeControlCharacters(value: string): boolean { + return unsafeControlCharacters.test(value); +} + +export const filterPresetProjectIdSchema = z + .string() + .startsWith('proj_') + .min(6) + .max(FILTER_PRESET_PROJECT_ID_MAX_LENGTH) + .refine( + value => + value.length > FILTER_PRESET_PROJECT_ID_MAX_LENGTH || + !hasUnsafeControlCharacters(value), + { + message: 'Project id cannot contain control characters' + } + ); + +export const filterPresetIdSchema = z + .string() + .superRefine((value, context) => { + if (value.length > FILTER_PRESET_ID_MAX_LENGTH) { + context.addIssue({ + code: 'custom', + message: `Preset id must contain at most ${FILTER_PRESET_ID_MAX_LENGTH} characters` + }); + return; + } + if (hasUnsafeControlCharacters(value)) { + context.addIssue({ + code: 'custom', + message: 'Preset id cannot contain control characters' + }); + } + }) + .trim() + .min(1) + .max(FILTER_PRESET_ID_MAX_LENGTH); + +export const filterPresetNameSchema = z + .string() + .superRefine((value, context) => { + if (value.length > FILTER_PRESET_NAME_MAX_LENGTH) { + context.addIssue({ + code: 'custom', + message: `Preset name must contain at most ${FILTER_PRESET_NAME_MAX_LENGTH} characters` + }); + return; + } + if (hasUnsafeControlCharacters(value)) { + context.addIssue({ + code: 'custom', + message: 'Preset name cannot contain control characters' + }); + } + }) + .trim() + .min(1) + .max(FILTER_PRESET_NAME_MAX_LENGTH); + +function inspectBoundedString( + value: unknown, + maxLength: number, + path: Array, + context: z.core.$RefinementCtx +): void { + if (typeof value !== 'string') return; + if (value.length > maxLength) { + context.addIssue({ + code: 'custom', + path, + message: `String must contain at most ${maxLength} characters` + }); + return; + } + if (hasUnsafeControlCharacters(value)) { + context.addIssue({ + code: 'custom', + path, + message: 'Preset state cannot contain control characters' + }); + } +} + +function ownDataValue( + object: Record, + key: string, + context: z.core.$RefinementCtx +): unknown { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) return undefined; + if (!('value' in descriptor)) { + context.addIssue({ + code: 'custom', + path: [key], + message: 'Preset state must contain plain data properties' + }); + return undefined; + } + return descriptor.value; +} + +function inspectBoundedStringArray( + object: Record, + key: string, + maxItems: number, + context: z.core.$RefinementCtx +): void { + const value = ownDataValue(object, key, context); + if (!Array.isArray(value)) return; + if (value.length > maxItems) { + context.addIssue({ + code: 'custom', + path: [key], + message: `Array must contain at most ${maxItems} values` + }); + return; + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor && !('value' in descriptor)) { + context.addIssue({ + code: 'custom', + path: [key, index], + message: 'Preset state must contain plain data properties' + }); + continue; + } + inspectBoundedString( + descriptor?.value, + FILTER_PRESET_VALUE_MAX_LENGTH, + [key, index], + context + ); + } +} + +const controlFreePresetStateInputSchema = z.unknown().superRefine( + (input, context) => { + if ( + typeof input !== 'object' || + input === null || + Array.isArray(input) + ) { + return; + } + const record = input as Record; + const prototype = Object.getPrototypeOf(record); + if (prototype !== Object.prototype && prototype !== null) { + context.addIssue({ + code: 'custom', + message: 'Preset state must be a plain object' + }); + return; + } + let topLevelKeyCount = 0; + for (const key in record) { + topLevelKeyCount += 1; + if ( + topLevelKeyCount > FILTER_PRESET_TOP_LEVEL_KEYS.size || + !Object.hasOwn(record, key) || + !FILTER_PRESET_TOP_LEVEL_KEYS.has(key) + ) { + context.addIssue({ + code: 'custom', + message: 'Preset state contains unknown or inherited fields' + }); + return; + } + } + + ownDataValue(record, 'version', context); + inspectBoundedString( + ownDataValue(record, 'provider', context), + 10, + ['provider'], + context + ); + inspectBoundedString( + ownDataValue(record, 'source', context), + 10, + ['source'], + context + ); + inspectBoundedString( + ownDataValue(record, 'view', context), + 10, + ['view'], + context + ); + inspectBoundedString( + ownDataValue(record, 'query', context), + FILTER_PRESET_QUERY_MAX_LENGTH, + ['query'], + context + ); + inspectBoundedStringArray( + record, + 'stateCategories', + FILTER_PRESET_STATE_CATEGORY_LIMIT, + context + ); + for (const key of [ + 'statuses', + 'assignees', + 'priorities', + 'externalProjects', + 'labels' + ]) { + inspectBoundedStringArray( + record, + key, + FILTER_PRESET_VALUE_LIMIT, + context + ); + } + + const collapsedGroups = ownDataValue( + record, + 'collapsedGroups', + context + ); + if ( + typeof collapsedGroups === 'object' && + collapsedGroups !== null && + !Array.isArray(collapsedGroups) + ) { + const prototype = Object.getPrototypeOf(collapsedGroups); + if (prototype !== Object.prototype && prototype !== null) { + context.addIssue({ + code: 'custom', + path: ['collapsedGroups'], + message: 'Collapse overrides must be a plain object' + }); + return; + } + let groupCount = 0; + for (const key in collapsedGroups) { + groupCount += 1; + if ( + groupCount > FILTER_PRESET_COLLAPSED_GROUP_LIMIT || + !Object.hasOwn(collapsedGroups, key) + ) { + context.addIssue({ + code: 'custom', + path: ['collapsedGroups'], + message: `At most ${FILTER_PRESET_COLLAPSED_GROUP_LIMIT} collapse overrides are allowed` + }); + return; + } + inspectBoundedString( + key, + FILTER_PRESET_VALUE_MAX_LENGTH, + ['collapsedGroups', key], + context + ); + const descriptor = Object.getOwnPropertyDescriptor( + collapsedGroups, + key + ); + if (descriptor && !('value' in descriptor)) { + context.addIssue({ + code: 'custom', + path: ['collapsedGroups', key], + message: 'Preset state must contain plain data properties' + }); + } + } + } + } +); + +export const filterPresetStateSchema = controlFreePresetStateInputSchema + .pipe(browsePreferencesV1Schema) + .superRefine((state, context) => { + if (state.provider === null) { + context.addIssue({ + code: 'custom', + path: ['provider'], + message: 'A project preset must record its provider' + }); + } else if (state.source !== 'all' && state.source !== state.provider) { + context.addIssue({ + code: 'custom', + path: ['source'], + message: 'A project preset source must match its provider' + }); + } + }); + +export const filterPresetSchema = z + .object({ + id: filterPresetIdSchema, + projectId: filterPresetProjectIdSchema, + name: filterPresetNameSchema, + state: filterPresetStateSchema, + position: z.number().int().min(0).max(FILTER_PRESET_LIMIT - 1) + }) + .strict(); +export type FilterPreset = z.infer; + +export const filterPresetSummarySchema = filterPresetSchema + .pick({ + id: true, + projectId: true, + name: true, + position: true + }) + .strict(); +export type FilterPresetSummary = z.infer; + +export function filterPresetSummary( + preset: FilterPreset +): FilterPresetSummary { + return filterPresetSummarySchema.parse({ + id: preset.id, + projectId: preset.projectId, + name: preset.name, + position: preset.position + }); +} + +const filterPresetOrderInputSchema = z.unknown().superRefine( + (input, context) => { + if (Array.isArray(input) && input.length > FILTER_PRESET_LIMIT) { + context.addIssue({ + code: 'custom', + message: `Preset order must contain at most ${FILTER_PRESET_LIMIT} ids` + }); + } + } +); + +export const filterPresetOrderSchema = filterPresetOrderInputSchema.pipe( + z.array(filterPresetIdSchema).max(FILTER_PRESET_LIMIT) +); + +/** + * Persisted uniqueness must not vary with the host locale. NFKC also makes + * compatibility-equivalent spellings (for example full-width letters) + * collide instead of creating visually duplicate preset names. + */ +export function normalizePresetName(name: string): string { + const normalized = filterPresetNameSchema + .parse(name) + .normalize('NFKC') + .toLowerCase(); + if (normalized.length > FILTER_PRESET_NORMALIZED_NAME_MAX_LENGTH) { + throw new Error('Normalized filter preset name is too long'); + } + return normalized; +} + +/** + * A reorder must be an exact permutation of the visible, parseable preset + * ids. Anything else means the client is stale, so reject rather than guess. + */ +export function resolvePresetOrder( + currentIds: readonly string[], + requestedIds: readonly string[] +): string[] { + const currentValues = filterPresetOrderSchema.parse(currentIds); + const requestedValues = filterPresetOrderSchema.parse(requestedIds); + if (requestedValues.length !== currentValues.length) { + throw new Error('Preset order must list every preset exactly once'); + } + const current = new Set(currentValues); + if (current.size !== currentValues.length) { + throw new Error('Stored preset order contains duplicate ids'); + } + const seen = new Set(); + for (const id of requestedValues) { + if (!current.has(id)) throw new Error(`Unknown filter preset: ${id}`); + if (seen.has(id)) throw new Error(`Duplicate filter preset: ${id}`); + seen.add(id); + } + return [...requestedValues]; +} + +export function serializeFilterPresetState(state: BrowsePreferences): string { + const serialized = JSON.stringify(filterPresetStateSchema.parse(state)); + if ( + new TextEncoder().encode(serialized).byteLength > + FILTER_PRESET_STATE_JSON_MAX_LENGTH + ) { + throw new Error('Filter preset state is too large'); + } + return serialized; +} diff --git a/plugins/taskboard/server.ts b/plugins/taskboard/server.ts index f078f0c..54322f9 100644 --- a/plugins/taskboard/server.ts +++ b/plugins/taskboard/server.ts @@ -1,11 +1,19 @@ -import type { BbPluginApi, PluginRpcHandlers } from '@get-bb/plugin-sdk'; import { + PLUGIN_CLI_OUTPUT_MAX_BYTES, + type BbPluginApi, + type PluginRpcHandlers +} from '@get-bb/plugin-sdk'; +import { + FILTER_PRESET_STATE_JSON_MAX_LENGTH, bbProjectIdSchema, createIssueMetadataSchema, escapeExternalInlineText, escapeExternalJsonOutput, + filterPresetStateSchema, + filterPresetSummary, formatWorkItemContext, issueDraftRecordSchema, + normalizePresetName, projectConfigMutationSchema, projectCredentialsInteractionResponseSchema, projectSourceConfigSchema, @@ -16,6 +24,7 @@ import { type CreateIssueContext, type CreateIssueInput, type CreateIssueMetadata, + type FilterPreset, type IssueDraftRecord, type ProjectConfigMutation, type ProjectConfigView, @@ -25,10 +34,12 @@ import { type TrackerProject, type WorkItem, type WorkItemDetail, + type WorkItemFilterField, type WorkSource, type WorkStatusOption, type WorkSourceStatus } from './contract.js'; +import { filterWorkItemsByAttributes } from './browse.js'; import { buildIssueDraftPrompt, parseIssueDraftOutput, @@ -84,6 +95,14 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +export function formatFilterPresetCliJson(value: unknown): string { + const output = escapeExternalJsonOutput(JSON.stringify(value)); + if (new TextEncoder().encode(output).byteLength > PLUGIN_CLI_OUTPUT_MAX_BYTES) { + throw new Error('Filter preset output exceeds the plugin CLI limit'); + } + return output; +} + function issueDraftRequestKey(requestId: string): string { return `${ISSUE_DRAFT_REQUEST_PREFIX}${requestId}`; } @@ -164,13 +183,25 @@ interface ParsedCliArguments { jiraEmail: string | undefined; jiraJql: string | undefined; statusId: string | undefined; + preset: string | undefined; + fromState: string | undefined; json: boolean; cached: boolean; } const CLI_OPTIONS_BY_COMMAND = new Map>([ ['status', new Set(['--project', '--json'])], - ['list', new Set(['--project', '--source', '--query', '--cached', '--json'])], + [ + 'list', + new Set([ + '--project', + '--source', + '--query', + '--preset', + '--cached', + '--json' + ]) + ], ['show', new Set(['--project', '--json'])], ['refresh', new Set(['--project', '--json'])], ['transitions', new Set(['--project', '--json'])], @@ -187,10 +218,11 @@ const CLI_OPTIONS_BY_COMMAND = new Map>([ '--json' ]) ], - ['credentials', new Set(['--project', '--json'])] + ['credentials', new Set(['--project', '--json'])], + ['presets', new Set(['--project', '--from-state', '--json'])] ]); -function parseCliArguments( +export function parseTaskboardCliArguments( command: string, argv: string[] ): ParsedCliArguments { @@ -207,6 +239,8 @@ function parseCliArguments( let jiraEmail: string | undefined; let jiraJql: string | undefined; let statusId: string | undefined; + let preset: string | undefined; + let fromState: string | undefined; let json = false; let cached = false; @@ -216,7 +250,12 @@ function parseCliArguments( value === undefined || value.startsWith('--') || (value.length === 0 && - !['--linear-team', '--jira-url', '--jira-email'].includes(flag)) + ![ + '--linear-team', + '--jira-url', + '--jira-email', + '--query' + ].includes(flag)) ) { throw new Error(`${flag} requires a value`); } @@ -260,6 +299,15 @@ function parseCliArguments( } else if (argument === '--status') { statusId = valueAfter(argument, index); index += 1; + } else if (argument === '--preset') { + preset = valueAfter(argument, index); + index += 1; + } else if (argument === '--from-state') { + fromState = valueAfter(argument, index); + if (fromState.length > FILTER_PRESET_STATE_JSON_MAX_LENGTH) { + throw new Error('--from-state is too large'); + } + index += 1; } else if (argument === '--json') { json = true; } else if (argument === '--cached') { @@ -267,6 +315,14 @@ function parseCliArguments( } } + if ( + command === 'presets' && + fromState !== undefined && + (positionals[0] ?? 'list') !== 'save' + ) { + throw new Error('--from-state is only valid with presets save'); + } + return { positionals, projectId, @@ -277,11 +333,45 @@ function parseCliArguments( jiraEmail, jiraJql, statusId, + preset, + fromState, json, cached }; } +export function resolvePresetListSelection( + preset: FilterPreset | undefined, + explicitSource: WorkSource | undefined, + explicitQuery: string | undefined, + enabledFilters: readonly WorkItemFilterField[] +) { + const enabled = new Set(enabledFilters); + const presetSource = + preset && preset.state.source !== 'all' + ? preset.state.source + : undefined; + return { + source: explicitSource ?? presetSource, + query: explicitQuery ?? preset?.state.query, + stateCategories: + preset && enabled.has('state') ? preset.state.stateCategories : [], + attributeFilters: preset + ? { + statuses: enabled.has('status') ? preset.state.statuses : [], + assignees: enabled.has('assignee') ? preset.state.assignees : [], + priorities: enabled.has('priority') + ? preset.state.priorities + : [], + projects: enabled.has('project') + ? preset.state.externalProjects + : [], + labels: enabled.has('labels') ? preset.state.labels : [] + } + : null + }; +} + function formatProjectConfig(config: ProjectConfigView): string { return [ `Project\t${config.projectId}`, @@ -603,6 +693,93 @@ export default async function plugin(bb: BbPluginApi) { await projectById(projectId); } + const resolvePresetByName = ( + projectId: string, + name: string + ): FilterPreset => { + const presets = store.listFilterPresets(projectId); + const normalized = normalizePresetName(name); + const match = presets.find( + candidate => normalizePresetName(candidate.name) === normalized + ); + if (match) return match; + const available = presets.map(candidate => candidate.name).join(', '); + throw new Error( + available + ? `Unknown filter preset "${name}". Available: ${available}` + : `Unknown filter preset "${name}". This project has no presets.` + ); + }; + + function publishFilterPresetsChanged(projectId: string): void { + bb.realtime.publish('taskboard:presets-changed', { projectId }); + } + + async function assertPresetProviderIsCurrent( + projectId: string, + preset: FilterPreset + ): Promise { + await waitForMutations(projectId, SOURCES); + const provider = projectConfig(projectId, true).source; + if (preset.state.provider !== provider) { + const savedProvider = preset.state.provider + ? sourceName(preset.state.provider) + : 'an unknown provider'; + throw new Error( + `Filter preset "${preset.name}" was saved for ${savedProvider} but this project now uses ${sourceName(provider)}` + ); + } + } + + function assertPresetStateMatchesCurrentProvider( + projectId: string, + state: FilterPreset['state'] + ): void { + const provider = projectConfig(projectId, true).source; + if (state.provider !== provider) { + throw new Error( + `Preset provider must match this project's ${sourceName(provider)} provider` + ); + } + } + + function assertPresetWriteMatchesCurrentProvider( + projectId: string, + id: string | undefined, + state: FilterPreset['state'] + ): void { + if (id) { + const existing = store + .listFilterPresets(projectId) + .find(preset => preset.id === id); + if (!existing) throw new Error(`Unknown filter preset: ${id}`); + if (JSON.stringify(existing.state) !== JSON.stringify(state)) { + throw new Error('Renaming a filter preset cannot change its state'); + } + return; + } + assertPresetStateMatchesCurrentProvider(projectId, state); + } + + function saveFilterPresetLinearized(input: { + projectId: string; + id?: string; + name: string; + state: FilterPreset['state']; + }): Promise<{ preset: FilterPreset; presets: FilterPreset[] }> { + return enqueueMutation(input.projectId, SOURCES, async () => { + assertPresetWriteMatchesCurrentProvider( + input.projectId, + input.id, + input.state + ); + const preset = store.saveFilterPreset(input); + const presets = store.listFilterPresets(input.projectId); + publishFilterPresetsChanged(input.projectId); + return { preset, presets }; + }); + } + async function fallbackGithubRepos(projectId: string): Promise { const project = (await liveProjects()).find( entry => entry.id === projectId @@ -1834,6 +2011,35 @@ export default async function plugin(bb: BbPluginApi) { source: null }); return { settings }; + }, + async listFilterPresets(input) { + await assertProjectExists(input.projectId); + return { presets: store.listFilterPresets(input.projectId) }; + }, + async saveFilterPreset(input) { + await assertProjectExists(input.projectId); + const result = await saveFilterPresetLinearized({ + projectId: input.projectId, + ...(input.id ? { id: input.id } : {}), + name: input.name, + state: input.state + }); + return { + preset: filterPresetSummary(result.preset), + presets: result.presets + }; + }, + async deleteFilterPreset(input) { + await assertProjectExists(input.projectId); + const presets = store.deleteFilterPreset(input.projectId, input.id); + publishFilterPresetsChanged(input.projectId); + return { presets }; + }, + async reorderFilterPresets(input) { + await assertProjectExists(input.projectId); + const presets = store.reorderFilterPresets(input.projectId, input.ids); + publishFilterPresetsChanged(input.projectId); + return { presets }; } }; bb.rpc.register(taskboardRpcContract, handlers); @@ -1927,7 +2133,9 @@ export default async function plugin(bb: BbPluginApi) { name: 'list', summary: 'List cached project work, refreshing first by default', usage: - 'bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json]' + 'bb taskboard list [--project ] ' + + '[--source linear|github|jira] [--query ] ' + + '[--preset ] [--cached] [--json]' }, { name: 'show', @@ -1963,6 +2171,18 @@ export default async function plugin(bb: BbPluginApi) { name: 'credentials', summary: 'Open a secure form for project connector credentials', usage: 'bb taskboard credentials [--project ] [--json]' + }, + { + name: 'presets', + summary: 'List, save, rename, or delete project filter presets', + usage: + 'bb taskboard presets list [--project ] [--json]\n' + + 'bb taskboard presets save --from-state ' + + '[--project ] [--json]\n' + + 'bb taskboard presets rename ' + + '[--project ] [--json]\n' + + 'bb taskboard presets delete ' + + '[--project ] [--json]' } ], async run(argv, ctx) { @@ -1972,7 +2192,7 @@ export default async function plugin(bb: BbPluginApi) { firstArgument && !firstArgument.startsWith('--') ); const command = hasExplicitCommand ? firstArgument! : 'status'; - const args = parseCliArguments( + const args = parseTaskboardCliArguments( command, hasExplicitCommand ? argv.slice(1) : argv ); @@ -2058,7 +2278,9 @@ export default async function plugin(bb: BbPluginApi) { if (command === 'list') { if (args.positionals.length > 0) { throw new Error( - 'Usage: bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json]' + 'Usage: bb taskboard list [--project ] ' + + '[--source linear|github|jira] [--query ] ' + + '[--preset ] [--cached] [--json]' ); } const sourceValue = args.source; @@ -2068,23 +2290,56 @@ export default async function plugin(bb: BbPluginApi) { if (parsedSource && !parsedSource.success) { throw new Error('Source must be linear, github, or jira'); } - const source = parsedSource?.data; const project = await requireProject(); + // Explicit --source/--query flags beat a --preset's saved values; + // a preset source of "all" means the preset applies no filter. + const preset = args.preset + ? resolvePresetByName(project.id, args.preset) + : undefined; + if (preset) { + await assertPresetProviderIsCurrent(project.id, preset); + } + const selection = resolvePresetListSelection( + preset, + parsedSource?.data, + args.query, + store.projectBoardSettings(project.id).enabledFilters + ); + const { source, query } = selection; if (source) { await assertSelectedSourceAfterMutations(project.id, source); } if (!args.cached) await syncAll(project.id, source, true); + // Sync may wait behind a project reconfiguration. Recheck the + // preset's provider at the read boundary so old-provider facets can + // never filter a freshly switched provider's items. + if (preset) { + await assertPresetProviderIsCurrent(project.id, preset); + } const items = store.list({ projectId: project.id, ...(source ? { source } : {}), - ...(args.query ? { query: args.query } : {}), - limit: 200 + ...(query ? { query } : {}), + ...(selection.stateCategories.length > 0 + ? { stateCategories: selection.stateCategories } + : {}), + limit: 500 }); + const narrowedItems = ( + selection.attributeFilters + ? filterWorkItemsByAttributes( + items, + selection.attributeFilters + ) + : items + ).slice(0, 200); return { exitCode: 0, stdout: args.json - ? escapeExternalJsonOutput(JSON.stringify({ items }, null, 2)) - : items + ? escapeExternalJsonOutput( + JSON.stringify({ items: narrowedItems }, null, 2) + ) + : narrowedItems .map( item => `${escapeExternalInlineText(item.bbProjectId)}\t${sourceName(item.source)}\t${escapeExternalInlineText(item.key)}\t${escapeExternalInlineText(item.status)}\t${escapeExternalInlineText(item.assignee ?? '-')}\t${escapeExternalInlineText(item.title)}` @@ -2289,6 +2544,109 @@ export default async function plugin(bb: BbPluginApi) { : formatCredentialStatus(config) }; } + if (command === 'presets') { + const verb = args.positionals[0] ?? 'list'; + const rest = args.positionals.slice(1); + if (!['list', 'save', 'rename', 'delete'].includes(verb)) { + throw new Error( + 'Usage: bb taskboard presets ...' + ); + } + const project = await requireProject(); + + if (verb === 'list') { + if (rest.length > 0) { + throw new Error( + 'Usage: bb taskboard presets list ' + + '[--project ] [--json]' + ); + } + const presets = store.listFilterPresets(project.id); + return { + exitCode: 0, + stdout: args.json + ? formatFilterPresetCliJson({ presets }) + : presets.length > 0 + ? presets + .map(item => escapeExternalInlineText(item.name)) + .join('\n') + : 'This project has no filter presets.' + }; + } + + if (verb === 'save') { + if (rest.length !== 1 || !args.fromState) { + throw new Error( + 'Usage: bb taskboard presets save ' + + '--from-state [--project ] [--json]' + ); + } + let fromStateJson: unknown; + try { + fromStateJson = JSON.parse(args.fromState); + } catch { + throw new Error('--from-state must be valid JSON'); + } + const parsedState = + filterPresetStateSchema.safeParse(fromStateJson); + if (!parsedState.success) { + throw new Error( + '--from-state is not a valid project browse preference state' + ); + } + const { preset } = await saveFilterPresetLinearized({ + projectId: project.id, + name: rest[0]!, + state: parsedState.data + }); + return { + exitCode: 0, + stdout: args.json + ? formatFilterPresetCliJson({ preset }) + : `Saved preset "${escapeExternalInlineText(preset.name)}"` + }; + } + + if (verb === 'rename') { + if (rest.length !== 2) { + throw new Error( + 'Usage: bb taskboard presets rename ' + + '[--project ] [--json]' + ); + } + const existing = resolvePresetByName(project.id, rest[0]!); + const { preset } = await saveFilterPresetLinearized({ + projectId: project.id, + id: existing.id, + name: rest[1]!, + state: existing.state + }); + return { + exitCode: 0, + stdout: args.json + ? formatFilterPresetCliJson({ preset }) + : `Renamed preset "${escapeExternalInlineText(existing.name)}" to "${escapeExternalInlineText(preset.name)}"` + }; + } + + if (verb === 'delete') { + if (rest.length !== 1) { + throw new Error( + 'Usage: bb taskboard presets delete ' + + '[--project ] [--json]' + ); + } + const existing = resolvePresetByName(project.id, rest[0]!); + const presets = store.deleteFilterPreset(project.id, existing.id); + publishFilterPresetsChanged(project.id); + return { + exitCode: 0, + stdout: args.json + ? formatFilterPresetCliJson({ presets }) + : `Deleted preset "${escapeExternalInlineText(existing.name)}"` + }; + } + } throw new Error('Unknown bb taskboard command'); } catch (error) { return { exitCode: 1, stderr: `${errorMessage(error)}\n` }; diff --git a/plugins/taskboard/store.ts b/plugins/taskboard/store.ts index 8c78f2c..28489ee 100644 --- a/plugins/taskboard/store.ts +++ b/plugins/taskboard/store.ts @@ -1,9 +1,22 @@ +import { randomUUID } from 'node:crypto'; import type { BbPluginApi } from '@get-bb/plugin-sdk'; import { + FILTER_PRESET_LIMIT, + FILTER_PRESET_PROJECT_STATE_BYTES_MAX, defaultProjectBoardSettings, + filterPresetIdSchema, + filterPresetNameSchema, + filterPresetOrderSchema, + filterPresetProjectIdSchema, + filterPresetSchema, + filterPresetStateSchema, + normalizePresetName, projectBoardSettingsSchema, projectSourceConfigSchema, + resolvePresetOrder, + serializeFilterPresetState, workItemSchema, + type FilterPreset, type ProjectBoardSettings, type ProjectSourceConfig, type WorkItem, @@ -55,6 +68,15 @@ interface ProjectBoardSettingsRow { status_order_json: string; } +interface FilterPresetRow { + id: string; + bb_project_id: string; + name: string; + name_normalized: string; + filters_json: string; + position: number; +} + export interface StoredSyncState { lastSyncedAt: string | null; error: string | null; @@ -118,10 +140,46 @@ function boardSettingsFromRow( }); } +function filterPresetFromRow(row: FilterPresetRow): FilterPreset | null { + const state = parseJsonSafely(row.filters_json); + if (state === undefined) return null; + const parsed = filterPresetSchema.safeParse({ + id: row.id, + projectId: row.bb_project_id, + name: row.name, + state, + position: row.position + }); + if (!parsed.success) return null; + let normalizedName: string; + try { + normalizedName = normalizePresetName(parsed.data.name); + } catch { + return null; + } + if ( + parsed.data.id !== row.id || + parsed.data.projectId !== row.bb_project_id || + parsed.data.name !== row.name || + normalizedName !== row.name_normalized + ) { + return null; + } + return parsed.data; +} + function escapeLike(value: string): string { return value.replace(/[\\%_]/gu, character => `\\${character}`); } +function parseJsonSafely(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + export function createWorkItemStore(bb: BbPluginApi) { const db = bb.storage.database(); bb.storage.migrate(db, [ @@ -323,6 +381,35 @@ export function createWorkItemStore(bb: BbPluginApi) { status_order_json TEXT NOT NULL, updated_at TEXT NOT NULL ); + `, + ` + CREATE TABLE project_filter_presets ( + id TEXT NOT NULL PRIMARY KEY + CHECK (length(id) BETWEEN 1 AND 100), + bb_project_id TEXT NOT NULL + CHECK ( + substr(bb_project_id, 1, 5) = 'proj_' AND + length(bb_project_id) BETWEEN 6 AND 500 + ), + name TEXT NOT NULL + CHECK (length(name) BETWEEN 1 AND 60), + name_normalized TEXT NOT NULL + CHECK (length(name_normalized) BETWEEN 1 AND 240), + filters_json TEXT NOT NULL + CHECK ( + length(CAST(filters_json AS BLOB)) BETWEEN 1 AND 910000 + ), + position INTEGER NOT NULL + CHECK (position >= 0 AND position < 50), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (bb_project_id, name_normalized) + ); + + CREATE INDEX idx_filter_presets_project + ON project_filter_presets( + bb_project_id, position, created_at, id + ); ` ]); @@ -433,6 +520,40 @@ export function createWorkItemStore(bb: BbPluginApi) { WHERE bb_project_id = ? `); + const readFilterPresets = db.prepare<[string], FilterPresetRow>(` + SELECT + id, bb_project_id, name, name_normalized, filters_json, position + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY position ASC, created_at ASC, id ASC + LIMIT ${FILTER_PRESET_LIMIT + 1} + `); + + const readFilterPreset = db.prepare<[string, string], FilterPresetRow>(` + SELECT + id, bb_project_id, name, name_normalized, filters_json, position + FROM project_filter_presets + WHERE bb_project_id = ? AND id = ? + `); + + const readFilterPresetStateBytes = db.prepare< + [string], + { total_bytes: number } + >(` + SELECT COALESCE(SUM(length(CAST(filters_json AS BLOB))), 0) AS total_bytes + FROM project_filter_presets + WHERE bb_project_id = ? + `); + + const readFilterPresetStateBytesExcluding = db.prepare< + [string, string], + { total_bytes: number } + >(` + SELECT COALESCE(SUM(length(CAST(filters_json AS BLOB))), 0) AS total_bytes + FROM project_filter_presets + WHERE bb_project_id = ? AND id <> ? + `); + const clearSourceTransaction = db.transaction( (projectId: string, source: WorkSource) => { db.prepare<[string, WorkSource]>( @@ -451,6 +572,14 @@ export function createWorkItemStore(bb: BbPluginApi) { return projectSourceConfigSchema.parse({ projectId, ...defaults }); } + function visibleFilterPresets(projectId: string): FilterPreset[] { + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null) + .slice(0, FILTER_PRESET_LIMIT); + } + return { upsert(item: WorkItem) { writeItem(item); @@ -683,6 +812,206 @@ export function createWorkItemStore(bb: BbPluginApi) { readProjectBoardSettings.get(settings.projectId)! ); }, + listFilterPresets(projectId: string): FilterPreset[] { + const parsedProjectId = filterPresetProjectIdSchema.parse(projectId); + return visibleFilterPresets(parsedProjectId); + }, + saveFilterPreset(input: { + projectId: string; + id?: string; + name: string; + state: FilterPreset['state']; + }): FilterPreset { + const projectId = filterPresetProjectIdSchema.parse(input.projectId); + const id = input.id + ? filterPresetIdSchema.parse(input.id) + : undefined; + const name = filterPresetNameSchema.parse(input.name); + const normalized = normalizePresetName(name); + const state = filterPresetStateSchema.parse(input.state); + const serializedState = serializeFilterPresetState(state); + const now = new Date().toISOString(); + + function readSavedPreset(id: string): FilterPreset { + const row = readFilterPreset.get(projectId, id); + const saved = row ? filterPresetFromRow(row) : null; + if (!saved) { + throw new Error('Saved filter preset could not be read back'); + } + return saved; + } + + return db.transaction(() => { + const existingStateBytes = id + ? (readFilterPresetStateBytesExcluding.get(projectId, id) + ?.total_bytes ?? 0) + : (readFilterPresetStateBytes.get(projectId)?.total_bytes ?? 0); + const nextStateBytes = new TextEncoder().encode(serializedState).byteLength; + if ( + existingStateBytes + nextStateBytes > + FILTER_PRESET_PROJECT_STATE_BYTES_MAX + ) { + throw new Error( + `Filter presets for this project exceed the ${FILTER_PRESET_PROJECT_STATE_BYTES_MAX}-byte limit` + ); + } + + const conflict = db + .prepare<[string, string], { id: string; name: string }>( + ` + SELECT id, name + FROM project_filter_presets + WHERE bb_project_id = ? AND name_normalized = ? + ` + ) + .get(projectId, normalized); + if (conflict && conflict.id !== id) { + throw new Error( + `A filter preset named "${conflict.name}" already exists` + ); + } + + if (id) { + const existing = readFilterPreset.get(projectId, id); + if (!existing) throw new Error(`Unknown filter preset: ${id}`); + db.prepare<[string, string, string, string, string, string]>( + ` + UPDATE project_filter_presets + SET name = ?, name_normalized = ?, filters_json = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ).run( + name, + normalized, + serializedState, + now, + projectId, + id + ); + return readSavedPreset(id); + } + + const rows = readFilterPresets.all(projectId); + if (rows.length >= FILTER_PRESET_LIMIT) { + throw new Error( + `A project can have at most ${FILTER_PRESET_LIMIT} filter presets` + ); + } + + const updatePosition = db.prepare<[number, string, string, string]>( + `UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ?` + ); + rows.forEach((row, index) => { + if (row.position === index) return; + updatePosition.run(index, now, projectId, row.id); + }); + + const newId = filterPresetIdSchema.parse( + `fp_${randomUUID().replaceAll('-', '')}` + ); + db.prepare< + [string, string, string, string, string, number, string, string] + >( + ` + INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + newId, + projectId, + name, + normalized, + serializedState, + rows.length, + now, + now + ); + return readSavedPreset(newId); + })(); + }, + // Deleting an unknown id is a deliberate no-op, unlike saveFilterPreset + // and reorderFilterPresets which throw. Delete is idempotent, and the + // callers resync from the returned list rather than trusting a local + // delta, so a stale id produces no visible inconsistency. + deleteFilterPreset(projectId: string, id: string): FilterPreset[] { + const parsedProjectId = filterPresetProjectIdSchema.parse(projectId); + const parsedId = filterPresetIdSchema.parse(id); + return db.transaction(() => { + const deletion = db.prepare<[string, string]>( + `DELETE FROM project_filter_presets + WHERE bb_project_id = ? AND id = ?` + ).run(parsedProjectId, parsedId); + if (deletion.changes === 0) { + return visibleFilterPresets(parsedProjectId); + } + // Renumber every remaining row, including any that fail to parse: + // an unreadable row is invisible to clients but still occupies a + // position, so leaving it out here would let it collide with a + // visible row forever instead of just until the next delete. + const remaining = readFilterPresets.all(parsedProjectId); + if (remaining.length > FILTER_PRESET_LIMIT) { + return visibleFilterPresets(parsedProjectId); + } + const now = new Date().toISOString(); + const updatePosition = db.prepare<[number, string, string, string]>( + ` + UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ); + remaining.forEach((row, index) => { + if (row.position === index) return; + updatePosition.run(index, now, parsedProjectId, row.id); + }); + return visibleFilterPresets(parsedProjectId); + })(); + }, + reorderFilterPresets( + projectId: string, + ids: readonly string[] + ): FilterPreset[] { + const parsedProjectId = filterPresetProjectIdSchema.parse(projectId); + const parsedIds = filterPresetOrderSchema.parse(ids); + return db.transaction(() => { + const rows = readFilterPresets.all(parsedProjectId); + if (rows.length > FILTER_PRESET_LIMIT) { + throw new Error('Stored filter preset limit exceeded'); + } + // A client can only ever request an order for presets it was + // shown, and listFilterPresets hides rows that fail to parse. So + // validate against, and renumber, only the parseable subset — + // otherwise one corrupt row permanently blocks every reorder for + // this project, since resolvePresetOrder demands an exact + // permutation of every stored id. An unreadable row keeps its old + // position and may end up sharing it with a visible row; that is + // harmless because the corrupt row is never displayed, and the + // next delete renumbers every row (visible or not) contiguously + // from 0 anyway. + const currentIds = rows + .filter(row => filterPresetFromRow(row) !== null) + .map(row => row.id); + const ordered = resolvePresetOrder(currentIds, parsedIds); + const positionById = new Map(rows.map(row => [row.id, row.position])); + const now = new Date().toISOString(); + const updatePosition = db.prepare<[number, string, string, string]>( + ` + UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ); + ordered.forEach((id, index) => { + if (positionById.get(id) === index) return; + updatePosition.run(index, now, parsedProjectId, id); + }); + return visibleFilterPresets(parsedProjectId); + })(); + }, configuredProjectIds(): string[] { return db .prepare<[], { bb_project_id: string }>( diff --git a/plugins/taskboard/test/app-ui.test.ts b/plugins/taskboard/test/app-ui.test.ts index c2698c2..36fe76a 100644 --- a/plugins/taskboard/test/app-ui.test.ts +++ b/plugins/taskboard/test/app-ui.test.ts @@ -24,6 +24,53 @@ test('canonicalizes provider facet casing before rendering and persistence', () assert.match(app, /sameStringValues\(current\.assignees, nextAssignees\)/u); }); +test('applies project presets through the released preference store', () => { + assert.match(app, /useProjectFilterPresets\(projectId\)/u); + assert.match(app, /useRealtime\('taskboard:presets-changed'/u); + assert.match(app, /projectIdRef\.current !== projectId/u); + assert.match(app, /loadedProjectId === projectId/u); + assert.match(app, /projectId === null \? null : presetState\.presets/u); + assert.match(app, /preset\.projectId !== projectId/u); + assert.match(app, /preset\.state\.provider !== authoritativeProvider/u); + assert.match(app, /browsePreferenceStore\.set\(preferenceScope, preset\.state\)/u); + assert.doesNotMatch(app, /setCommittedQuery\(preset\.state\.query/u); + assert.match(app, /state: preferences/u); + assert.match(app, /Could not load presets:/u); + assert.match(app, /Save current view as/u); + assert.match(app, /mutationInFlightRef/u); + assert.match(app, /value=\{nameDrafts\[preset\.id\] \?\? preset\.name\}/u); +}); + +test('keeps preset refreshes, drafts, and focus non-disruptive', () => { + const hook = app.match( + /function useProjectFilterPresets[\s\S]*?\nfunction loadRightPanelPinned/u + )?.[0]; + assert.ok(hook, 'Missing project preset hook'); + assert.ok( + [...hook.matchAll(/void reload\(\{ background: true \}\)/gu)].length >= 3, + 'Realtime, reconnect, and mutation reconciliation must stay in the background' + ); + assert.match(hook, /if \(options\.background\) \{\s*setRefreshError\(message\)/u); + assert.doesNotMatch( + hook, + /if \(options\.background\) \{[^}]*setPresets\(\[\]\)/u + ); + assert.match(app, /Keeping your loaded presets and edits/u); + assert.match(app, /authoritativeNamesRef/u); + assert.match(app, /presetActionButtonRefs/u); + assert.match(app, /restorePresetFocus\(preset\.id/u); + assert.match(app, /flex min-w-0 flex-col gap-1\.5 @sm:flex-row/u); + assert.match(app, /max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10/u); +}); + +test('announces preset apply and keeps save errors with the draft', () => { + assert.match(app, /toast\.success\(`Applied preset/u); + assert.match(app, //u); + assert.match(app, /setPresetSaveError\(message\)/u); + assert.match(app, /aria-invalid=\{presetSaveError !== null\}/u); + assert.match(app, /id=\{presetSaveErrorId\}[\s\S]*?role="alert"/u); +}); + test('keeps List measured and Kanban unconstrained', () => { assert.match(app, /data-taskboard-list-measure/u); assert.match(app, /max-w-\[56rem\]/u); diff --git a/plugins/taskboard/test/distribution.test.ts b/plugins/taskboard/test/distribution.test.ts index a716f58..dd6c9fb 100644 --- a/plugins/taskboard/test/distribution.test.ts +++ b/plugins/taskboard/test/distribution.test.ts @@ -164,3 +164,33 @@ test('has no active registry publication automation or credential path', () => { } } }); + +test('credits merged contributors without reviving superseded filter storage', async () => { + assert.match( + rootReadme, + /https:\/\/github\.com\/stephendolan/u + ); + assert.match(rootReadme, /https:\/\/github\.com\/RIP21/u); + assert.match(taskboardReadme, /named filter presets/u); + assert.match(usageReadme, /configurable Compact limit/u); + + for (const path of ['filter-state.ts', 'work-schemas.ts']) { + await assert.rejects( + readFile(new URL(`../${path}`, import.meta.url), 'utf8'), + /ENOENT/u + ); + } + + const activeTaskboardSource = await Promise.all([ + readFile(new URL('../app.tsx', import.meta.url), 'utf8'), + readFile(new URL('../contract.ts', import.meta.url), 'utf8'), + readFile(new URL('../server.ts', import.meta.url), 'utf8'), + readFile(new URL('../store.ts', import.meta.url), 'utf8'), + ]); + for (const source of activeTaskboardSource) { + assert.doesNotMatch( + source, + /project_filter_state|getBoardFilterState|saveBoardFilterState/u + ); + } +}); diff --git a/plugins/taskboard/test/filter-presets-backend.test.ts b/plugins/taskboard/test/filter-presets-backend.test.ts new file mode 100644 index 0000000..666bae8 --- /dev/null +++ b/plugins/taskboard/test/filter-presets-backend.test.ts @@ -0,0 +1,504 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { PLUGIN_CLI_OUTPUT_MAX_BYTES } from '@get-bb/plugin-sdk'; +import Database from 'better-sqlite3'; +import { + defaultBrowsePreferences, + type BrowsePreferences +} from '../browse-preferences.ts'; +import { + FILTER_PRESET_PROJECT_STATE_BYTES_MAX, + FILTER_PRESET_STATE_JSON_MAX_LENGTH, + filterPresetSummary, + serializeFilterPresetState +} from '../filter-presets.ts'; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && specifier.endsWith('.js')) { + const sourceUrl = new URL( + `${specifier.slice(0, -'.js'.length)}.ts`, + context.parentURL + ); + if (existsSync(fileURLToPath(sourceUrl))) { + return { shortCircuit: true, url: sourceUrl.href }; + } + } + return nextResolve(specifier, context); + } +}); + +const { createWorkItemStore } = await import('../store.ts'); +const { + formatFilterPresetCliJson, + parseTaskboardCliArguments, + resolvePresetListSelection +} = await import('../server.ts'); +type StoreBb = Parameters[0]; +type WorkItemStore = ReturnType; + +function createStore(): { db: Database.Database; store: WorkItemStore } { + const db = new Database(':memory:'); + const bb = { + storage: { + database: () => db, + migrate(database: Database.Database, migrations: readonly string[]) { + for (const migration of migrations) database.exec(migration); + } + } + } as unknown as StoreBb; + return { db, store: createWorkItemStore(bb) }; +} + +function state(provider: 'github' | 'linear' = 'github'): BrowsePreferences { + return { + ...defaultBrowsePreferences({ provider }), + query: 'security', + view: 'kanban' as const, + stateCategories: ['in_progress' as const], + statuses: ['Review'], + assignees: ['Mateo'], + priorities: ['High'], + externalProjects: ['Taskboard'], + labels: ['backend'], + collapsedGroups: { done: false } + }; +} + +function maximumUtf8State(): BrowsePreferences { + const values = Array.from({ length: 100 }, (_, index) => + `${'界'.repeat(497)}${String(index).padStart(3, '0')}` + ); + return { + ...state(), + source: 'github' as const, + query: '界'.repeat(500), + stateCategories: [ + 'backlog', + 'todo', + 'in_progress', + 'done', + 'canceled' + ], + statuses: values, + assignees: values, + priorities: values, + externalProjects: values, + labels: values, + collapsedGroups: Object.fromEntries(values.map(value => [value, true])) + }; +} + +test('appends only the preset migration and round-trips complete state', () => { + const { db, store } = createStore(); + try { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map(row => (row as { name: string }).name); + assert.ok(tables.includes('project_filter_presets')); + assert.equal(tables.includes('project_filter_state'), false); + const tableSql = db + .prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'project_filter_presets'" + ) + .get() as { sql: string }; + assert.match(tableSql.sql, /id TEXT NOT NULL PRIMARY KEY/u); + assert.match(tableSql.sql, /910000/u); + + const saved = store.saveFilterPreset({ + projectId: 'proj_alpha', + name: 'My work', + state: state() + }); + assert.deepEqual(store.listFilterPresets('proj_alpha'), [saved]); + assert.deepEqual(saved.state, state()); + } finally { + db.close(); + } +}); + +test('isolates names and ids between projects', () => { + const { db, store } = createStore(); + try { + const alpha = store.saveFilterPreset({ + projectId: 'proj_alpha', + name: 'My Work', + state: state() + }); + const beta = store.saveFilterPreset({ + projectId: 'proj_beta', + name: 'my work', + state: state() + }); + + assert.throws(() => + store.saveFilterPreset({ + projectId: 'proj_alpha', + name: 'MY WORK', + state: state() + }) + ); + assert.throws(() => + store.saveFilterPreset({ + projectId: 'proj_beta', + id: alpha.id, + name: 'Moved', + state: state() + }) + ); + assert.deepEqual( + store.deleteFilterPreset('proj_beta', alpha.id).map(item => item.id), + [beta.id] + ); + assert.equal(store.listFilterPresets('proj_alpha')[0]?.id, alpha.id); + } finally { + db.close(); + } +}); + +test('contains corrupt rows and makes reorder exact and atomic', () => { + const { db, store } = createStore(); + try { + const presets = ['One', 'Two', 'Three'].map(name => + store.saveFilterPreset({ + projectId: 'proj_alpha', + name, + state: state() + }) + ); + db.prepare( + `INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'fp_corrupt', + 'proj_alpha', + 'Corrupt', + 'corrupt', + '{}', + 3, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + const serializedState = serializeFilterPresetState(state()); + db.prepare( + `INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'fp_trimmed ', + 'proj_alpha', + 'Trimmed ', + 'trimmed', + serializedState, + 4, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + db.prepare( + `INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'fp_bad_normalized', + 'proj_alpha', + 'Canonical', + 'not-canonical', + serializedState, + 5, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + db.prepare( + `INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'fp_expanded_name', + 'proj_alpha', + '\ufdfa'.repeat(14), + 'expanded-name', + serializedState, + 6, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + + assert.deepEqual( + store.listFilterPresets('proj_alpha').map(item => item.name), + ['One', 'Two', 'Three'] + ); + const requested = [presets[2]!.id, presets[0]!.id, presets[1]!.id]; + assert.deepEqual( + store.reorderFilterPresets('proj_alpha', requested).map(item => item.id), + requested + ); + + const before = db + .prepare( + `SELECT id, position, updated_at + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY id` + ) + .all('proj_alpha'); + assert.throws(() => + store.reorderFilterPresets('proj_alpha', requested.slice(1)) + ); + assert.deepEqual( + db.prepare( + `SELECT id, position, updated_at + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY id` + ).all('proj_alpha'), + before + ); + + db.prepare( + `UPDATE project_filter_presets + SET position = position + 10 + WHERE bb_project_id = ?` + ).run('proj_alpha'); + const gapped = db + .prepare( + `SELECT id, position, updated_at + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY id` + ) + .all('proj_alpha'); + store.deleteFilterPreset('proj_alpha', 'fp_absent'); + assert.deepEqual( + db.prepare( + `SELECT id, position, updated_at + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY id` + ).all('proj_alpha'), + gapped + ); + } finally { + db.close(); + } +}); + +test('enforces the per-project preset limit without blocking updates', () => { + const { db, store } = createStore(); + try { + const saved = Array.from({ length: 50 }, (_, index) => + store.saveFilterPreset({ + projectId: 'proj_alpha', + name: `Preset ${index}`, + state: state() + }) + ); + assert.throws(() => + store.saveFilterPreset({ + projectId: 'proj_alpha', + name: 'One too many', + state: state() + }) + ); + assert.equal(store.listFilterPresets('proj_alpha').length, 50); + const renamed = store.saveFilterPreset({ + projectId: 'proj_alpha', + id: saved[0]!.id, + name: 'Renamed at capacity', + state: saved[0]!.state + }); + assert.equal(renamed.name, 'Renamed at capacity'); + } finally { + db.close(); + } +}); + +test('bounds reads and fails closed when raw rows exceed the preset limit', () => { + const { db, store } = createStore(); + try { + Array.from({ length: 50 }, (_, index) => + store.saveFilterPreset({ + projectId: 'proj_alpha', + name: `Preset ${index}`, + state: state() + }) + ); + db.prepare( + `INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'fp_overflow', + 'proj_alpha', + 'Overflow', + 'overflow', + '{}', + 0, + '2025-01-01T00:00:00.000Z', + '2025-01-01T00:00:00.000Z' + ); + + const visible = store.listFilterPresets('proj_alpha'); + assert.equal(visible.length, 50); + assert.throws(() => + store.reorderFilterPresets( + 'proj_alpha', + visible.map(preset => preset.id) + ) + ); + assert.throws(() => + store.saveFilterPreset({ + projectId: 'proj_alpha', + name: 'Blocked by overflow', + state: state() + }) + ); + } finally { + db.close(); + } +}); + +test('rejects --from-state on wrong verbs and preserves explicit precedence', () => { + assert.throws(() => + parseTaskboardCliArguments('presets', [ + 'list', + '--from-state', + '{}' + ]) + ); + assert.equal( + parseTaskboardCliArguments('presets', [ + 'save', + 'Mine', + '--from-state', + '{}' + ]).fromState, + '{}' + ); + assert.equal( + parseTaskboardCliArguments('list', ['--query', '']).query, + '' + ); + + const preset = { + id: 'fp_1', + projectId: 'proj_alpha', + name: 'Mine', + state: { ...state('linear'), source: 'linear' as const }, + position: 0 + }; + const selection = resolvePresetListSelection( + preset, + 'github', + '', + ['state', 'status', 'assignee', 'priority', 'project', 'labels'] + ); + assert.equal(selection.source, 'github'); + assert.equal(selection.query, ''); + assert.deepEqual(selection.attributeFilters, { + statuses: ['Review'], + assignees: ['Mateo'], + priorities: ['High'], + projects: ['Taskboard'], + labels: ['backend'] + }); + + const disabled = resolvePresetListSelection( + preset, + undefined, + undefined, + ['assignee'] + ); + assert.deepEqual(disabled.stateCategories, []); + assert.deepEqual(disabled.attributeFilters, { + statuses: [], + assignees: ['Mateo'], + priorities: [], + projects: [], + labels: [] + }); +}); + +test('keeps maximum aggregate preset JSON compact and under the CLI cap', () => { + const nearMaxState = maximumUtf8State(); + const nearMaxSerialized = serializeFilterPresetState(nearMaxState); + assert.ok(new TextEncoder().encode(nearMaxSerialized).byteLength > 890_000); + + const presets = [{ + id: 'fp_max', + projectId: `proj_${'p'.repeat(495)}`, + name: 'Maximum state', + state: nearMaxState, + position: 0 + }]; + const output = formatFilterPresetCliJson({ presets }); + const outputBytes = new TextEncoder().encode(output).byteLength; + assert.ok(outputBytes > 700_000); + assert.ok(outputBytes <= PLUGIN_CLI_OUTPUT_MAX_BYTES); + assert.equal(output.includes('\n'), false); + const saveOutput = formatFilterPresetCliJson({ + preset: filterPresetSummary(presets[0]!), + presets + }); + assert.ok( + new TextEncoder().encode(saveOutput).byteLength <= + PLUGIN_CLI_OUTPUT_MAX_BYTES + ); + assert.equal('state' in filterPresetSummary(presets[0]!), false); + assert.throws(() => + formatFilterPresetCliJson({ + value: 'x'.repeat(PLUGIN_CLI_OUTPUT_MAX_BYTES + 1) + }) + ); +}); + +test('enforces a transactional aggregate state-byte limit per project', () => { + const { db, store } = createStore(); + try { + const maximum = maximumUtf8State(); + const maximumBytes = new TextEncoder().encode( + serializeFilterPresetState(maximum) + ).byteLength; + assert.ok(maximumBytes <= FILTER_PRESET_STATE_JSON_MAX_LENGTH); + assert.ok(maximumBytes < FILTER_PRESET_PROJECT_STATE_BYTES_MAX); + + const saved = store.saveFilterPreset({ + projectId: 'proj_aggregate', + name: 'Maximum', + state: maximum + }); + const medium = { + ...state(), + labels: Array.from({ length: 100 }, (_, index) => + `${String(index).padStart(3, '0')}${'x'.repeat(497)}` + ) + }; + assert.throws(() => + store.saveFilterPreset({ + projectId: 'proj_aggregate', + name: 'Too much aggregate state', + state: medium + }) + ); + assert.equal(store.listFilterPresets('proj_aggregate').length, 1); + + const renamed = store.saveFilterPreset({ + projectId: 'proj_aggregate', + id: saved.id, + name: 'Maximum renamed', + state: maximum + }); + assert.equal(renamed.name, 'Maximum renamed'); + } finally { + db.close(); + } +}); diff --git a/plugins/taskboard/test/filter-presets.test.ts b/plugins/taskboard/test/filter-presets.test.ts new file mode 100644 index 0000000..6df885f --- /dev/null +++ b/plugins/taskboard/test/filter-presets.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + defaultBrowsePreferences, + type BrowsePreferences +} from '../browse-preferences.ts'; +import { + FILTER_PRESET_LIMIT, + FILTER_PRESET_STATE_JSON_MAX_LENGTH, + filterPresetIdSchema, + filterPresetNameSchema, + filterPresetOrderSchema, + filterPresetProjectIdSchema, + filterPresetSchema, + filterPresetStateSchema, + normalizePresetName, + resolvePresetOrder, + serializeFilterPresetState +} from '../filter-presets.ts'; + +function githubPreferences() { + return defaultBrowsePreferences({ provider: 'github' }); +} + +function maximumUtf8Preferences(): BrowsePreferences { + const values = Array.from({ length: 100 }, (_, index) => + `${'界'.repeat(497)}${String(index).padStart(3, '0')}` + ); + return { + ...githubPreferences(), + source: 'github' as const, + view: 'kanban' as const, + query: '界'.repeat(500), + stateCategories: [ + 'backlog', + 'todo', + 'in_progress', + 'done', + 'canceled' + ], + statuses: values, + assignees: values, + priorities: values, + externalProjects: values, + labels: values, + collapsedGroups: Object.fromEntries(values.map(value => [value, true])) + }; +} + +test('accepts a complete strict project browse preference snapshot', () => { + const state = { + ...githubPreferences(), + query: 'security', + view: 'kanban' as const, + stateCategories: ['in_progress' as const], + statuses: ['Review'], + assignees: ['Mateo'], + priorities: ['High'], + externalProjects: ['Taskboard'], + labels: ['backend'], + collapsedGroups: { done: false } + }; + const preset = filterPresetSchema.parse({ + id: 'fp_1', + projectId: 'proj_alpha', + name: 'My work', + state, + position: 0 + }); + + assert.deepEqual(preset.state, state); + assert.equal(preset.name, 'My work'); + assert.throws(() => + filterPresetStateSchema.parse({ ...state, unexpected: true }) + ); +}); + +test('requires a project provider and matching source', () => { + assert.throws(() => + filterPresetStateSchema.parse(defaultBrowsePreferences()) + ); + assert.throws(() => + filterPresetStateSchema.parse({ + ...githubPreferences(), + source: 'linear' + }) + ); + assert.equal( + filterPresetStateSchema.parse({ + ...githubPreferences(), + source: 'github' + }).source, + 'github' + ); +}); + +test('bounds names, ids, positions, order size, and state fields', () => { + const base = { + id: 'fp_1', + projectId: 'proj_alpha', + name: 'My work', + state: githubPreferences(), + position: 0 + }; + assert.throws(() => filterPresetSchema.parse({ ...base, name: ' ' })); + assert.throws(() => + filterPresetSchema.parse({ ...base, name: 'x'.repeat(61) }) + ); + assert.throws(() => filterPresetIdSchema.parse('x'.repeat(101))); + assert.throws(() => filterPresetProjectIdSchema.parse('proj_')); + assert.throws(() => + filterPresetSchema.parse({ ...base, position: FILTER_PRESET_LIMIT }) + ); + assert.throws(() => + filterPresetOrderSchema.parse( + Array.from({ length: FILTER_PRESET_LIMIT + 1 }, (_, index) => + `fp_${index}` + ) + ) + ); + assert.throws(() => + filterPresetStateSchema.parse({ + ...githubPreferences(), + query: 'x'.repeat(501) + }) + ); + assert.throws(() => + filterPresetStateSchema.parse({ + ...githubPreferences(), + labels: Array.from({ length: 101 }, (_, index) => `label-${index}`) + }) + ); +}); + +test('rejects control characters in preset identity and state', () => { + for (const control of ['\n', '\u0000', '\u0085', '\u202e', '\u2067']) { + assert.throws(() => filterPresetNameSchema.parse(`left${control}right`)); + assert.throws(() => filterPresetIdSchema.parse(`fp_${control}`)); + assert.throws(() => + filterPresetStateSchema.parse({ + ...githubPreferences(), + assignees: [`left${control}right`] + }) + ); + } + assert.throws(() => + filterPresetStateSchema.parse({ + ...githubPreferences(), + assignees: ['Mateo\n'] + }) + ); +}); + +test('rejects oversized adversarial containers before inspecting elements', () => { + let indexedReads = 0; + const oversizedLabels = new Proxy([] as string[], { + get(target, property, receiver) { + if (property === 'length') return 1_000_000; + if (typeof property === 'string' && /^\d+$/u.test(property)) { + indexedReads += 1; + } + return Reflect.get(target, property, receiver); + } + }); + assert.throws(() => + filterPresetStateSchema.parse({ + ...githubPreferences(), + labels: oversizedLabels + }) + ); + assert.equal(indexedReads, 0); + + let queryGetterCalled = false; + const accessorState = { ...githubPreferences() }; + Object.defineProperty(accessorState, 'query', { + enumerable: true, + get() { + queryGetterCalled = true; + return 'unsafe'; + } + }); + assert.throws(() => filterPresetStateSchema.parse(accessorState)); + assert.equal(queryGetterCalled, false); + + let orderIndexedReads = 0; + const oversizedOrder = new Proxy([] as string[], { + get(target, property, receiver) { + if (property === 'length') return 100_000; + if (typeof property === 'string' && /^\d+$/u.test(property)) { + orderIndexedReads += 1; + } + return Reflect.get(target, property, receiver); + } + }); + assert.throws(() => filterPresetOrderSchema.parse(oversizedOrder)); + assert.equal(orderIndexedReads, 0); +}); + +test('accepts the maximum valid browse state inside the UTF-8 envelope', () => { + const largestState = maximumUtf8Preferences(); + const largestSerialized = serializeFilterPresetState(largestState); + const byteLength = new TextEncoder().encode(largestSerialized).byteLength; + assert.ok(byteLength <= FILTER_PRESET_STATE_JSON_MAX_LENGTH); + assert.ok(byteLength > 890_000); + assert.deepEqual(filterPresetStateSchema.parse(largestState), largestState); +}); + +test('normalizes unique names without depending on locale', () => { + assert.equal(normalizePresetName(' My Work '), 'my work'); + assert.equal(normalizePresetName('MY WORK'), normalizePresetName('my work')); + assert.equal(normalizePresetName('\uff2d\uff59 \uff37\uff4f\uff52\uff4b'), 'my work'); + assert.equal(normalizePresetName('\u0130'), 'i\u0307'); + assert.equal(normalizePresetName(' \u0130S '), 'i\u0307s'); + assert.equal(normalizePresetName('\u0130'.repeat(60)).length, 120); +}); + +test('resolves only exact preset order permutations', () => { + assert.deepEqual( + resolvePresetOrder(['a', 'b', 'c'], ['c', 'a', 'b']), + ['c', 'a', 'b'] + ); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a'])); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a', 'z'])); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a', 'a'])); + assert.throws(() => resolvePresetOrder(['a', 'a'], ['a', 'a'])); +}); diff --git a/plugins/usage-tracker/CHANGELOG.md b/plugins/usage-tracker/CHANGELOG.md index 5c80ef8..abf8051 100644 --- a/plugins/usage-tracker/CHANGELOG.md +++ b/plugins/usage-tracker/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Usage Tracker are documented here. - A Compact limit setting chooses whether the collapsed percentage and progress bar show weekly or five-hour usage. Weekly is the default. + Contributed by [Stephen Dolan (@stephendolan)](https://github.com/stephendolan). ## 0.1.2 - 2026-08-17 diff --git a/plugins/usage-tracker/README.md b/plugins/usage-tracker/README.md index 8cad020..21b9fbb 100644 --- a/plugins/usage-tracker/README.md +++ b/plugins/usage-tracker/README.md @@ -36,6 +36,9 @@ usage reading, without adding a navigation item or a separate plugin page. - Cleans up its UI on plugin reload, disable, or removal and works alongside a custom thread list such as t3sidebar. +The configurable Compact limit was contributed by +[Stephen Dolan (@stephendolan)](https://github.com/stephendolan). + ## Install Usage Tracker requires BB 0.38 or newer. Install its tracking Git release: diff --git a/plugins/usage-tracker/lib/sidebar-strip.ts b/plugins/usage-tracker/lib/sidebar-strip.ts index 4502971..6bdc228 100644 --- a/plugins/usage-tracker/lib/sidebar-strip.ts +++ b/plugins/usage-tracker/lib/sidebar-strip.ts @@ -15,9 +15,9 @@ import { import { providerMark } from "./provider-marks.ts"; import { mergeLastKnownWindows, - sidebarUsagePrimarySummary, - sidebarUsagePrimaryWindow, - sidebarUsageSummary, + selectSidebarUsagePrimary, + sidebarUsagePrimaryAccessibleText, + sidebarUsagePrimarySelectionSummary, sidebarUsageWindows, } from "./sidebar-usage.ts"; @@ -299,7 +299,8 @@ function visibleSidebarFooterMenu(): HTMLElement | null { export function mountSidebarUsageStrip(signal: AbortSignal): () => void { let root: HTMLLIElement | null = null; - let snapshot = readCachedSnapshot(); + let lastKnownSnapshot = readCachedSnapshot(); + let currentSnapshot: UsageSnapshot | null = null; let enabledProviderIds = readCachedProviderIds(); let compactLimit = readCachedCompactLimit(); let selectedProviderId: SidebarProviderId | null = null; @@ -313,7 +314,9 @@ export function mountSidebarUsageStrip(signal: AbortSignal): () => void { let disposed = false; const providerFor = (providerId: SidebarProviderId): ProviderUsage => - snapshot?.providers.find((provider) => provider.id === providerId) ?? + lastKnownSnapshot?.providers.find( + (provider) => provider.id === providerId, + ) ?? emptyProvider(providerId); const render = (): void => { @@ -342,6 +345,19 @@ export function mountSidebarUsageStrip(signal: AbortSignal): () => void { for (const providerId of enabledProviderIds) { const provider = providerFor(providerId); + const currentProvider = currentSnapshot?.providers.find( + (candidate) => candidate.id === providerId, + ); + const primary = selectSidebarUsagePrimary( + currentProvider, + provider, + compactLimit, + ); + const primaryAccessibleText = sidebarUsagePrimaryAccessibleText( + provider.name, + compactLimit, + primary, + ); const button = element("button", "usage-tracker-sidebar__provider"); button.type = "button"; button.dataset.provider = providerId; @@ -350,20 +366,19 @@ export function mountSidebarUsageStrip(signal: AbortSignal): () => void { "aria-expanded", String(selectedProviderId === providerId), ); - button.setAttribute("aria-label", `${provider.name}: ${sidebarUsageSummary(provider)}`); - button.title = `${provider.name} · ${sidebarUsageSummary(provider)} · Click for 5-hour and weekly details`; + button.setAttribute("aria-label", primaryAccessibleText); + button.title = primaryAccessibleText; const mark = element("span", "usage-tracker-sidebar__mark"); mark.append(providerGlyph(providerId)); - const primaryWindow = sidebarUsagePrimaryWindow(provider, compactLimit); const reading = element( "span", "usage-tracker-sidebar__reading", - isLoading && snapshot === null + isLoading && lastKnownSnapshot === null ? "…" - : sidebarUsagePrimarySummary(provider, compactLimit), + : sidebarUsagePrimarySelectionSummary(primary), ); - button.append(mark, progressRail(primaryWindow), reading); + button.append(mark, progressRail(primary.window), reading); button.addEventListener("click", () => { selectedProviderId = selectedProviderId === providerId ? null : providerId; @@ -438,8 +453,9 @@ export function mountSidebarUsageStrip(signal: AbortSignal): () => void { if (!response.ok || !payload.ok || payload.result === undefined) { throw new Error(payload.error?.message ?? "Usage is unavailable."); } - snapshot = mergeSnapshot(payload.result, snapshot); - cacheSnapshot(snapshot); + currentSnapshot = payload.result; + lastKnownSnapshot = mergeSnapshot(currentSnapshot, lastKnownSnapshot); + cacheSnapshot(lastKnownSnapshot); lastLoadedAt = Date.now(); } catch (error) { if (!requestController.signal.aborted) { diff --git a/plugins/usage-tracker/lib/sidebar-usage.ts b/plugins/usage-tracker/lib/sidebar-usage.ts index ace2b37..2821920 100644 --- a/plugins/usage-tracker/lib/sidebar-usage.ts +++ b/plugins/usage-tracker/lib/sidebar-usage.ts @@ -10,6 +10,18 @@ export interface SidebarUsageWindows { weekly: UsageWindow | null; } +export type SidebarUsagePrimaryFallback = + | "none" + | "current-alternative" + | "last-known" + | "unavailable"; + +export interface SidebarUsagePrimarySelection { + window: UsageWindow | null; + actualKind: CompactLimitOption | null; + fallback: SidebarUsagePrimaryFallback; +} + function isFiveHourLabel(label: string): boolean { const normalized = label.toLowerCase(); return ( @@ -50,22 +62,115 @@ export function sidebarUsageSummary(provider: ProviderUsage): string { return `${fiveHourValue}% 5h · ${weeklyValue}% wk`; } +function windowForKind( + windows: SidebarUsageWindows, + kind: CompactLimitOption, +): UsageWindow | null { + return kind === "Weekly" ? windows.weekly : windows.fiveHour; +} + +function alternateKind(kind: CompactLimitOption): CompactLimitOption { + return kind === "Weekly" ? "Five-hour" : "Weekly"; +} + +export function selectSidebarUsagePrimary( + currentProvider: ProviderUsage | undefined, + lastKnownProvider: ProviderUsage | undefined, + compactLimit: CompactLimitOption, +): SidebarUsagePrimarySelection { + const alternative = alternateKind(compactLimit); + + if (currentProvider !== undefined) { + const currentWindows = sidebarUsageWindows(currentProvider); + const preferredWindow = windowForKind(currentWindows, compactLimit); + if (preferredWindow !== null) { + return { + window: preferredWindow, + actualKind: compactLimit, + fallback: "none", + }; + } + + const alternativeWindow = windowForKind(currentWindows, alternative); + if (alternativeWindow !== null) { + return { + window: alternativeWindow, + actualKind: alternative, + fallback: "current-alternative", + }; + } + } + + if (lastKnownProvider !== undefined) { + const lastKnownWindows = sidebarUsageWindows(lastKnownProvider); + const preferredWindow = windowForKind(lastKnownWindows, compactLimit); + if (preferredWindow !== null) { + return { + window: preferredWindow, + actualKind: compactLimit, + fallback: "last-known", + }; + } + + const alternativeWindow = windowForKind(lastKnownWindows, alternative); + if (alternativeWindow !== null) { + return { + window: alternativeWindow, + actualKind: alternative, + fallback: "last-known", + }; + } + } + + return { window: null, actualKind: null, fallback: "unavailable" }; +} + +export function sidebarUsagePrimarySelectionSummary( + selection: SidebarUsagePrimarySelection, +): string { + return selection.window === null + ? "—%" + : `${formatUsedPercent(selection.window.usedPercent)}%`; +} + +export function sidebarUsagePrimaryAccessibleText( + providerName: string, + compactLimit: CompactLimitOption, + selection: SidebarUsagePrimarySelection, +): string { + const prefix = `${providerName} compact usage: ${compactLimit} configured`; + const action = "Open five-hour and weekly details."; + if (selection.window === null || selection.actualKind === null) { + return `${prefix}; no usage window is available. ${action}`; + } + + const actual = `${selection.actualKind} ${sidebarUsagePrimarySelectionSummary(selection)}`; + switch (selection.fallback) { + case "none": + return `${prefix}; showing ${actual}. ${action}`; + case "current-alternative": + return `${prefix}; showing ${actual} as fallback because ${compactLimit} is not currently reported. ${action}`; + case "last-known": + return `${prefix}; showing last-known ${actual} as fallback because no current usage window is reported. ${action}`; + case "unavailable": + return `${prefix}; no usage window is available. ${action}`; + } +} + export function sidebarUsagePrimaryWindow( provider: ProviderUsage, compactLimit: CompactLimitOption, ): UsageWindow | null { - const { fiveHour, weekly } = sidebarUsageWindows(provider); - return compactLimit === "Weekly" - ? weekly ?? fiveHour - : fiveHour ?? weekly; + return selectSidebarUsagePrimary(provider, undefined, compactLimit).window; } export function sidebarUsagePrimarySummary( provider: ProviderUsage, compactLimit: CompactLimitOption, ): string { - const primary = sidebarUsagePrimaryWindow(provider, compactLimit); - return primary === null ? "—%" : `${formatUsedPercent(primary.usedPercent)}%`; + return sidebarUsagePrimarySelectionSummary( + selectSidebarUsagePrimary(provider, undefined, compactLimit), + ); } export function mergeLastKnownWindows( diff --git a/plugins/usage-tracker/test/usage.test.ts b/plugins/usage-tracker/test/usage.test.ts index 03c2abf..6e361b4 100644 --- a/plugins/usage-tracker/test/usage.test.ts +++ b/plugins/usage-tracker/test/usage.test.ts @@ -17,6 +17,8 @@ import { } from "../lib/usage.ts"; import { mergeLastKnownWindows, + selectSidebarUsagePrimary, + sidebarUsagePrimaryAccessibleText, sidebarUsagePrimarySummary, sidebarUsagePrimaryWindow, sidebarUsageSummary, @@ -233,6 +235,133 @@ test("falls back when the configured compact window is unavailable", () => { assert.equal(sidebarUsagePrimarySummary(fiveHourOnly, "Weekly"), "120%"); }); +test("prefers a fresh alternative before merged last-known compact windows", () => { + const previous = normalizeUsage( + healthyResponse(), + { id: null, name: null }, + ).providers[0]!; + const weekly = sidebarUsageWindows(previous).weekly!; + const fiveHour = sidebarUsageWindows(previous).fiveHour!; + + const currentWeekly = { + ...previous, + windows: [{ ...weekly, usedPercent: 23, barPercent: 23 }], + }; + const mergedWeekly = mergeLastKnownWindows(currentWeekly, previous); + const fiveHourSelection = selectSidebarUsagePrimary( + currentWeekly, + mergedWeekly, + "Five-hour", + ); + + assert.equal(fiveHourSelection.actualKind, "Weekly"); + assert.equal(fiveHourSelection.fallback, "current-alternative"); + assert.equal(fiveHourSelection.window?.usedPercent, 23); + assert.equal(fiveHourSelection.window?.barPercent, 23); + assert.equal(sidebarUsageWindows(mergedWeekly).fiveHour?.usedPercent, 120); + + const currentFiveHour = { + ...previous, + windows: [{ ...fiveHour, usedPercent: 42, barPercent: 42 }], + }; + const mergedFiveHour = mergeLastKnownWindows(currentFiveHour, previous); + const weeklySelection = selectSidebarUsagePrimary( + currentFiveHour, + mergedFiveHour, + "Weekly", + ); + + assert.equal(weeklySelection.actualKind, "Five-hour"); + assert.equal(weeklySelection.fallback, "current-alternative"); + assert.equal(weeklySelection.window?.usedPercent, 42); + assert.equal(weeklySelection.window?.barPercent, 42); + assert.equal(sidebarUsageWindows(mergedFiveHour).weekly?.usedPercent, 17.25); +}); + +test("describes configured, actual, and fallback compact windows accessibly", () => { + const previous = normalizeUsage( + healthyResponse(), + { id: null, name: null }, + ).providers[0]!; + const weekly = sidebarUsageWindows(previous).weekly!; + const fiveHour = sidebarUsageWindows(previous).fiveHour!; + const exactWeekly = selectSidebarUsagePrimary(previous, previous, "Weekly"); + const exactFiveHour = selectSidebarUsagePrimary( + previous, + previous, + "Five-hour", + ); + const weeklyText = sidebarUsagePrimaryAccessibleText( + "Codex", + "Weekly", + exactWeekly, + ); + const fiveHourText = sidebarUsagePrimaryAccessibleText( + "Codex", + "Five-hour", + exactFiveHour, + ); + + assert.notEqual(weeklyText, fiveHourText); + assert.match(weeklyText, /Weekly configured; showing Weekly 17\.3%/u); + assert.match( + fiveHourText, + /Five-hour configured; showing Five-hour 120%/u, + ); + + const currentWeekly = { + ...previous, + windows: [{ ...weekly, usedPercent: 23, barPercent: 23 }], + }; + const currentFiveHour = { + ...previous, + windows: [{ ...fiveHour, usedPercent: 42, barPercent: 42 }], + }; + const weeklyFallbackText = sidebarUsagePrimaryAccessibleText( + "Codex", + "Five-hour", + selectSidebarUsagePrimary( + currentWeekly, + mergeLastKnownWindows(currentWeekly, previous), + "Five-hour", + ), + ); + const fiveHourFallbackText = sidebarUsagePrimaryAccessibleText( + "Codex", + "Weekly", + selectSidebarUsagePrimary( + currentFiveHour, + mergeLastKnownWindows(currentFiveHour, previous), + "Weekly", + ), + ); + + assert.notEqual(weeklyFallbackText, fiveHourFallbackText); + assert.match( + weeklyFallbackText, + /Five-hour configured; showing Weekly 23% as fallback/u, + ); + assert.match( + fiveHourFallbackText, + /Weekly configured; showing Five-hour 42% as fallback/u, + ); + + const lastKnownText = sidebarUsagePrimaryAccessibleText( + "Codex", + "Weekly", + selectSidebarUsagePrimary( + { ...previous, windows: [] }, + previous, + "Weekly", + ), + ); + assert.notEqual(lastKnownText, weeklyText); + assert.match( + lastKnownText, + /Weekly configured; showing last-known Weekly 17\.3% as fallback/u, + ); +}); + test("keeps last-known sidebar windows through partial and failed refreshes", () => { const previous = normalizeUsage( healthyResponse(), @@ -252,6 +381,14 @@ test("keeps last-known sidebar windows through partial and failed refreshes", () sidebarUsageSummary(mergeLastKnownWindows(failed, previous)), "120% 5h · 17.3% wk", ); + const lastKnownSelection = selectSidebarUsagePrimary( + { ...previous, windows: [] }, + previous, + "Weekly", + ); + assert.equal(lastKnownSelection.actualKind, "Weekly"); + assert.equal(lastKnownSelection.fallback, "last-known"); + assert.equal(lastKnownSelection.window?.usedPercent, 17.25); }); test("resolves the thread environment host", async () => {