Skip to content

Tasks 035 — AI stateless-per-request, ranking to the browser ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task.

Build-window note. T1–T3 are additive (whole monorepo stays green; the server ranking path keeps working in parallel). T4 reshapes the assistant server + regenerates the contract, which breaks the web assistant (view/hook lose their fields) — web is red from T4 and restored by T5. T6–T7 delete the now-orphaned server ranking path (api stays green; the web ranking hook already left it in T3). The two decisions the human approved at plan sign-off are baked in: delete the dead Gw2Client scan methods (T7) and re-source the missing-scope boundary from the browser scan (T3).


T1 — Move ranking arithmetic + the owned-items sum into the web (additive) ​

Satisfies: R2, R4 (sum half), SC4, SC6 (owned sum); foundation for R1.

  • [ ] RED: create apps/web/src/features/legendaries/__tests__/ranking.test.ts — carry over the fixtures and expectations from apps/api/src/legendaries/ranking.service.test.ts (toRankingRow null-poison cases, byPersonalProfitDescIdAsc ordering incl. null-last) and assert computeRanking(trees, priceMap, owned) equals the retired server ordering + per-row fields for a fixture. Create apps/web/src/shared/lib/gw2/__tests__/ownedItems.test.ts — carry over apps/api/src/account/account.service.owned.test.ts's four-source-sum fixture and assert buildOwnedItems(materials, bank, shared, characters) sums per id with null slots skipped. Run — both fail (modules absent).
  • [ ] GREEN: create apps/web/src/features/legendaries/ranking.ts — move toRankingRow, byPersonalProfitDescIdAsc, and the RankingRow/GatedInput types verbatim from ranking.service.ts/ranking.schema.ts, and add computeRanking(trees: ResolvedGraph[], prices: PriceMap, owned: Map<number, number>): RankingRow[] folding each tree through priceGraph → aggregateNeed → mergeOwnVsNeed (imports from @gw2priory/recipe-graph; the feature already has aggregateNeed.ts/mergeOwnVsNeed.ts wrappers) then toRankingRow, sorted by byPersonalProfitDescIdAsc. Create apps/web/src/shared/lib/gw2/ownedItems.ts with the pure buildOwnedItems (logic moved verbatim from AccountService.getOwnedItems:34-55).
  • [ ] Confirm teeth — flip byPersonalProfitDescIdAsc's null branch (null sorts first), watch the ordering assertion fail, restore.
  • [ ] Commit.

Verified by: the two new web tests green; pnpm --filter @gw2priory/web test green; nothing on the api side changed (server ranking still works — duplication is intentional and removed in T7).


T2 — Browser→ArenaNet owned-scan fetchers ​

Satisfies: R4 (fetch half), P1 #3.

  • [ ] RED: extend apps/web/src/shared/lib/gw2/__tests__/client.test.ts (MSW, base https://api.guildwars2.com/v2): fetchAccountBank/fetchAccountSharedInventory GET /account/{bank,inventory}?access_token=<key> and parse (Gw2ItemSlot|null)[]; fetchCharacters GETs /characters?ids=all&access_token=<key> and parses Gw2Character[] (each with bags[].inventory[]), and on a non-2xx bulk response falls back to /characters?access_token= (names) then /characters/:name/inventory?access_token= per name. Every call puts the key in ?access_token=, never a header (research V1/V2). Run — fails.
  • [ ] GREEN: add web-local Zod schemas Gw2ItemSlot ({ id, count }) and Gw2Character ({ name, bags: ({ inventory: (Gw2ItemSlot|null)[] } | null)[] }) to apps/web/src/shared/lib/gw2/schemas.ts (single consumer is the web — app-side, not a package), and implement the three fetchers in client.ts following the existing fetchAccountMaterials pattern (?access_token=, boundary parse, hashKey never needed here — key isn't a queryKey).
  • [ ] Confirm teeth — make fetchCharacters ignore the fallback (throw instead), watch the bulk-500 fallback test fail, restore.
  • [ ] Commit.

Verified by: client.test.ts green; pnpm --filter @gw2priory/web test green (still additive).


T3 — Rewire useLegendaryRanking to compute client-side ​

Satisfies: R1, R10 (ranking half), P1 #1, P1 #4, SC1 (wiring), SC7 (compute).

  • [ ] RED: rewrite apps/web/src/api/__tests__/useLegendaryRanking.test.tsx (MSW): the hook fetches the 21 Gen-1 trees from our /recipe-graph/:id (keyless) and prices + the four account calls from api.guildwars2.com with ?access_token=, issues no /legendaries/ranking request and no request carrying the key to our origin (P1 #1/SC1); the returned rows equal computeRanking(...) for a fixture; an ArenaNet 4xx on the account scan surfaces through the existing error/connect boundary — re-sourced there rather than from our old 403 (P1 #4/R10). Watch fail.
  • [ ] GREEN: rewrite apps/web/src/api/useLegendaryRanking.ts — a Suspense query that resolves the 21 GEN1_IDS trees, fetchPrices(union of collectPricedIds), and buildOwnedItems(fetchAccountMaterials, fetchAccountBank, fetchAccountSharedInventory, fetchCharacters), then returns computeRanking(trees, priceMap, owned). RankingRow now comes from features/legendaries/ranking.ts; update apps/web/src/api/index.ts to re-export it from there (not the generated type). Source GEN1_IDS web-side (a small committed const or reuse useLegendaries({generation:1}) ids). Key kept out of the queryKey via hashKey. RankingTable, RankingPage, MissingScopeBoundary need no change beyond the boundary re-source (R10).
  • [ ] Confirm teeth — point one tree fetch at our /legendaries/ranking, watch the "no origin ranking request" assertion fail, restore.
  • [ ] Commit.

Verified by: useLegendaryRanking.test.tsx + features/legendaries ranking tests green; pnpm --filter @gw2priory/web test && build green (React-compiler gate in build). The server /legendaries/ranking route still exists but the web no longer calls it (removed in T7).


T4 — Reshape the assistant server to a keyless intent classifier + regen OpenAPI ​

Satisfies: R5, R9 (assistant half), P2 #1, P2 #3, SC3. Build window: api green; web assistant red until T5.

  • [ ] RED: rewrite apps/api/src/assistant/assistant.controller.test.ts — POST /assistant/ask { question } with no Authorization header returns { intent }, makes no GW2 call, and carries Cache-Control: private, no-store; a blank question still 400s (unchanged validation). Update assistant.service.test.ts to expect the intent-only return (no ranking), and assistant.schema.test.ts to assert the reshaped union. Watch fail.
  • [ ] GREEN: in assistant.schema.ts, replace AssistantAnswer with the intent-only union ({ intent: 'closest_to_craft' } | { intent: 'unsupported'; message }), reusing AssistantIntent's shape and surfacing reason as message; drop the RankingRow import (research F1). In assistant.service.ts, drop the RankingService dependency — ask(question) returns parseIntent(question) mapped to the response (unsupported → message; closest_to_craft → bare intent). In assistant.controller.ts, drop the Authorization param, requireBearer, and the GW2 error branches (keep Surface B + the LLM/provider 502). In assistant.module.ts, drop the LegendariesModule import. Delete apps/api/src/assistant/closest-to-craft.ts + its test (moved web-side in T5). Regenerate the contract: pnpm --filter @gw2priory/api build && pnpm --filter @gw2priory/api generate:openapi; update generate-openapi assertions for the reshaped /assistant/ask.
  • [ ] Confirm teeth — have the controller read Authorization again and require it, watch the "no auth" test fail, restore.
  • [ ] Commit (including the regenerated apps/api/openapi.json).

Verified by: assistant.* api tests + generate-openapi green; pnpm --filter @gw2priory/api test && build green. Web assistant is expected red until T5.


T5 — Rewire the web assistant: compute the answer client-side ​

Satisfies: R5 (web half), R9 (orval), P2 #2, SC8 (assistant contract). Restores the web build.

  • [ ] Run pnpm --filter @gw2priory/web generate:api (orval) against T4's openapi.json; verify the diff touches only apps/web/src/api/generated/endpoints/assistant/**.
  • [ ] RED: rewrite apps/web/src/features/assistant/__tests__ — on a closest_to_craft intent the view renders the client-computed ranking (a summary line + name/gold list) with no summary/results coming from the server; on unsupported it renders the message. Watch fail.
  • [ ] GREEN: create apps/web/src/features/assistant/summary.ts with buildSummary + byRemainingGoldAscIdAsc moved verbatim from the deleted closest-to-craft.ts. Rewrite apps/web/src/api/useAskAssistant.ts to POST { question } with no Authorization header and the reshaped response type; drop the 403/MissingScopeError branch. In AssistantView.tsx, on closest_to_craft render a small ClosestToCraftAnswer({ apiKey }) that suspends on useLegendaryRanking(apiKey), sorts with byRemainingGoldAscIdAsc, and renders buildSummary(rows) + the list; drop the answer.summary/answer.results reads.
  • [ ] Confirm teeth — make buildSummary return a constant, watch the "summary names the closest legendary" assertion fail, restore.
  • [ ] Commit (generated assistant client + web edits).

Verified by: the assistant feature tests + pnpm --filter @gw2priory/web test && build green; whole web green again. pnpm verify:contract clean for the assistant path.


T6 — Remove the MCP priory_legendary_ranking tool ​

Satisfies: R6, P3 #1. Build window: api green.

  • [ ] RED: update apps/api/src/mcp/mcp.tools.test.ts to assert priory_legendary_ranking is absent and the nine other tools are present; update mcp.shape.test.ts to drop the shapeRankingRow case. Watch fail.
  • [ ] GREEN: remove the priory_legendary_ranking tool object and ranking from McpDeps in mcp.tools.ts; remove shapeRankingRow from mcp.shape.ts; drop the RankingService injection from mcp.controller.ts and the ranking field in the buildMcpServer({...}) call. (RankingService still exists — deleted in T7 — but the MCP no longer references it.)
  • [ ] Confirm teeth — re-add the tool name to the expected list, watch the "nine tools / absent" assertion fail, restore.
  • [ ] Commit.

Verified by: mcp.tools.test.ts + mcp.shape.test.ts + the rest of mcp.* green; the per-request X-GW2-Key tests (P3 #2/#3) unchanged and green; pnpm --filter @gw2priory/api test green.


T7 — Delete the orphaned server ranking path + dead scan methods; regen contract ​

Satisfies: R3, R8, R9, SC1, SC2, SC8 (ranking half). Build window: api green; web green (the ranking hook left this route in T3).

  • [ ] RED: add a structure/grep guard test asserting zero references to RankingService, ranking.schema, and getOwnedItems under apps/api/src; extend generate-openapi to assert no/legendaries/ranking path and no RankingListDto/GatedInput schema. Watch fail.
  • [ ] GREEN: delete ranking.service.ts (+test) and ranking.schema.ts (+test); remove the @Get('ranking') route + the RankingService field (+ now-unused requireBearer/mapGw2Error) from legendaries.controller.ts (+ its ranking-route tests); drop RankingService from legendaries.module.ts providers/exports. Delete AccountService.getOwnedItems (+ account.service.owned.test.ts) and the stale comment in account.module.ts. Delete the dead Gw2Client/Gw2Service methods accountBank/accountSharedInventory/characters/characterInventory (+ their tests) and, after grepping nothing else imports them, Gw2ItemSlotSchema / Gw2CharacterInventorySchema. Remove the now-stale /legendaries/ranking case from cache-policy.declarations.test.ts. Regenerate: pnpm --filter @gw2priory/api build && generate:openapi, then pnpm --filter @gw2priory/web generate:api (orval drops the ranking client — the web already stopped importing it in T3). Commit both regenerated artifacts.
  • [ ] Confirm teeth — reintroduce a RankingService import in one file, watch the grep-guard test fail, remove.
  • [ ] Commit.

Verified by: the grep guard + generate-openapi + all apps/api tests green; MCP + assistant + account tests green (kept capability intact); pnpm verify:contract clean (no ranking path/client); pnpm --filter @gw2priory/web test && build still green.


T8 — The closing audit: no endpoint caches per-user data ​

Satisfies: R7, SC5 (the epic's end-to-end invariant gate).

  • [ ] RED: extend apps/api/src/caching/cache-policy.declarations.test.ts to be exhaustive — enumerate every controller route (LegendariesController, RecipeGraphController, AssistantController, HealthController, McpController) and assert: every route resolves to a CachePolicy (or the interceptor default private, no-store, research V4); every public (Surface-A) route's handler declares no @Headers('authorization') parameter and its controller injects noAccountService; every non-public route is private, no-store. Watch the new assertions fail if seeded wrong.
  • [ ] GREEN: no production change expected (the invariant already holds structurally, research V4) — the task is the assertion itself. If any route is found cacheable-and-per-user, that is a real defect: fix the surface declaration, not the test.
  • [ ] Confirm teeth — temporarily decorate POST /assistant/ask with @SurfaceA(60), watch the audit fail (a per-user route made cacheable), restore.
  • [ ] Commit.

Verified by: the exhaustive cache-policy.declarations.test.ts green; a documented audit note recorded in spec.md's verification/traceability (SC5).


T9 — Verify end to end; traceability; status → implemented; PR ​

Satisfies: SC7, SC8, the CLAUDE.md definition of done.

  • [ ] Run the full gate from the repo root: pnpm lint && pnpm test && pnpm build && pnpm docs:build && pnpm verify:contract. All green.
  • [ ] Run the app (run skill / pnpm dev), connect a key, open the ranking page and the assistant: the ranking renders client-side and — in the browser network panel — the trees come from our /recipe-graph, the account scan + prices go to api.guildwars2.com, no /legendaries/ranking request hits our origin, and no request to our origin carries the key; ask "which am I closest to crafting" and confirm the assistant returns { intent } (no ranking rows on the wire) while the browser renders the answer. Record the observation (SC1/SC3/SC5).
  • [ ] Fill the spec.md Traceability table — every scenario/criterion → the real test name from T1–T8.
  • [ ] On the human's decision, transcribe spec.md status approved → implemented (in-branch, part of the PR diff) — and say it was the human's call, not the agent's.
  • [ ] Commit; open the PR (035-ai-ranking-reconcile → main) via superpowers:finishing-a-development-branch, first running superpowers:requesting-code-review and superpowers:receiving-code-review.

Verified by: the full gate green; the running app + network observation; the completed traceability table; code review on the branch before merge.


Notes ​

Staging area for decisions and surprises found during implementation — anything that turned out differently from what plan.md assumed. Move each into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • If /characters?ids=all 502s on a large account, the T2 per-character fallback covers it; if that too is slow enough to breach an interactive budget on a real device (research V1's tail), stop and record the Surface-B ranking fallback (docs/gaps/surface-b-ranking-fallback.md) as the trigger fired — do not build server ranking without a fresh decision.
  • If deleting Gw2ItemSlotSchema/Gw2CharacterInventorySchema (T7) breaks an unexpected importer, keep the schema and note the extra consumer — the plan assumed getOwnedItems was their only path.
  • If MSW does not intercept api.guildwars2.com for the new account/character fetchers, register the ArenaNet base in the shared web test server rather than mocking fetch per test.