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 fromapps/api/src/legendaries/ranking.service.test.ts(toRankingRownull-poison cases,byPersonalProfitDescIdAscordering incl. null-last) and assertcomputeRanking(trees, priceMap, owned)equals the retired server ordering + per-row fields for a fixture. Createapps/web/src/shared/lib/gw2/__tests__/ownedItems.test.ts— carry overapps/api/src/account/account.service.owned.test.ts's four-source-sum fixture and assertbuildOwnedItems(materials, bank, shared, characters)sums per id withnullslots skipped. Run — both fail (modules absent). - [ ] GREEN: create
apps/web/src/features/legendaries/ranking.ts— movetoRankingRow,byPersonalProfitDescIdAsc, and theRankingRow/GatedInputtypes verbatim fromranking.service.ts/ranking.schema.ts, and addcomputeRanking(trees: ResolvedGraph[], prices: PriceMap, owned: Map<number, number>): RankingRow[]folding each tree throughpriceGraph→aggregateNeed→mergeOwnVsNeed(imports from@gw2priory/recipe-graph; the feature already hasaggregateNeed.ts/mergeOwnVsNeed.tswrappers) thentoRankingRow, sorted bybyPersonalProfitDescIdAsc. Createapps/web/src/shared/lib/gw2/ownedItems.tswith the purebuildOwnedItems(logic moved verbatim fromAccountService.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, basehttps://api.guildwars2.com/v2):fetchAccountBank/fetchAccountSharedInventoryGET/account/{bank,inventory}?access_token=<key>and parse(Gw2ItemSlot|null)[];fetchCharactersGETs/characters?ids=all&access_token=<key>and parsesGw2Character[](each withbags[].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 }) andGw2Character({ name, bags: ({ inventory: (Gw2ItemSlot|null)[] } | null)[] }) toapps/web/src/shared/lib/gw2/schemas.ts(single consumer is the web — app-side, not a package), and implement the three fetchers inclient.tsfollowing the existingfetchAccountMaterialspattern (?access_token=, boundary parse,hashKeynever needed here — key isn't a queryKey). - [ ] Confirm teeth — make
fetchCharactersignore 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 fromapi.guildwars2.comwith?access_token=, issues no/legendaries/rankingrequest and no request carrying the key to our origin (P1 #1/SC1); the returned rows equalcomputeRanking(...)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 21GEN1_IDStrees,fetchPrices(union of collectPricedIds), andbuildOwnedItems(fetchAccountMaterials, fetchAccountBank, fetchAccountSharedInventory, fetchCharacters), then returnscomputeRanking(trees, priceMap, owned).RankingRownow comes fromfeatures/legendaries/ranking.ts; updateapps/web/src/api/index.tsto re-export it from there (not the generated type). SourceGEN1_IDSweb-side (a small committed const or reuseuseLegendaries({generation:1})ids). Key kept out of thequeryKeyviahashKey.RankingTable,RankingPage,MissingScopeBoundaryneed 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 noAuthorizationheader returns{ intent }, makes no GW2 call, and carriesCache-Control: private, no-store; a blank question still 400s (unchanged validation). Updateassistant.service.test.tsto expect the intent-only return (no ranking), andassistant.schema.test.tsto assert the reshaped union. Watch fail. - [ ] GREEN: in
assistant.schema.ts, replaceAssistantAnswerwith the intent-only union ({ intent: 'closest_to_craft' } | { intent: 'unsupported'; message }), reusingAssistantIntent's shape and surfacingreasonasmessage; drop theRankingRowimport (research F1). Inassistant.service.ts, drop theRankingServicedependency —ask(question)returnsparseIntent(question)mapped to the response (unsupported → message; closest_to_craft → bare intent). Inassistant.controller.ts, drop theAuthorizationparam,requireBearer, and the GW2 error branches (keep Surface B + the LLM/provider 502). Inassistant.module.ts, drop theLegendariesModuleimport. Deleteapps/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; updategenerate-openapiassertions for the reshaped/assistant/ask. - [ ] Confirm teeth — have the controller read
Authorizationagain 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'sopenapi.json; verify the diff touches onlyapps/web/src/api/generated/endpoints/assistant/**. - [ ] RED: rewrite
apps/web/src/features/assistant/__tests__— on aclosest_to_craftintent the view renders the client-computed ranking (a summary line + name/gold list) with nosummary/resultscoming from the server; onunsupportedit renders the message. Watch fail. - [ ] GREEN: create
apps/web/src/features/assistant/summary.tswithbuildSummary+byRemainingGoldAscIdAscmoved verbatim from the deletedclosest-to-craft.ts. Rewriteapps/web/src/api/useAskAssistant.tsto POST{ question }with noAuthorizationheader and the reshaped response type; drop the 403/MissingScopeErrorbranch. InAssistantView.tsx, onclosest_to_craftrender a smallClosestToCraftAnswer({ apiKey })that suspends onuseLegendaryRanking(apiKey), sorts withbyRemainingGoldAscIdAsc, and rendersbuildSummary(rows)+ the list; drop theanswer.summary/answer.resultsreads. - [ ] Confirm teeth — make
buildSummaryreturn 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.tsto assertpriory_legendary_rankingis absent and the nine other tools are present; updatemcp.shape.test.tsto drop theshapeRankingRowcase. Watch fail. - [ ] GREEN: remove the
priory_legendary_rankingtool object andrankingfromMcpDepsinmcp.tools.ts; removeshapeRankingRowfrommcp.shape.ts; drop theRankingServiceinjection frommcp.controller.tsand therankingfield in thebuildMcpServer({...})call. (RankingServicestill 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, andgetOwnedItemsunderapps/api/src; extendgenerate-openapito assert no/legendaries/rankingpath and noRankingListDto/GatedInputschema. Watch fail. - [ ] GREEN: delete
ranking.service.ts(+test) andranking.schema.ts(+test); remove the@Get('ranking')route + theRankingServicefield (+ now-unusedrequireBearer/mapGw2Error) fromlegendaries.controller.ts(+ its ranking-route tests); dropRankingServicefromlegendaries.module.tsproviders/exports. DeleteAccountService.getOwnedItems(+account.service.owned.test.ts) and the stale comment inaccount.module.ts. Delete the deadGw2Client/Gw2ServicemethodsaccountBank/accountSharedInventory/characters/characterInventory(+ their tests) and, after grepping nothing else imports them,Gw2ItemSlotSchema/Gw2CharacterInventorySchema. Remove the now-stale/legendaries/rankingcase fromcache-policy.declarations.test.ts. Regenerate:pnpm --filter @gw2priory/api build && generate:openapi, thenpnpm --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
RankingServiceimport 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.tsto be exhaustive — enumerate every controller route (LegendariesController,RecipeGraphController,AssistantController,HealthController,McpController) and assert: every route resolves to aCachePolicy(or the interceptor defaultprivate, no-store, research V4); everypublic(Surface-A) route's handler declares no@Headers('authorization')parameter and its controller injects noAccountService; every non-public route isprivate, 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/askwith@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 (
runskill /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 toapi.guildwars2.com, no/legendaries/rankingrequest 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.mdTraceability table — every scenario/criterion → the real test name from T1–T8. - [ ] On the human's decision, transcribe
spec.mdstatusapproved→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) viasuperpowers:finishing-a-development-branch, first runningsuperpowers:requesting-code-reviewandsuperpowers: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=all502s 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 assumedgetOwnedItemswas their only path. - If MSW does not intercept
api.guildwars2.comfor the new account/character fetchers, register the ArenaNet base in the shared web test server rather than mockingfetchper test.