Add full sprint pricing workflow and supporting infrastructure: - New sidebar navigation entry for the sprint dashboard - PocketBase category collection schema and migration script - Server API routes for sprint item claims, attachments, counts, and file proxying - Dev identity utilities and UI switcher for multi-user testing - Reusable FilterTabs component replacing ItemStatusTabs - Updated TypeScript types for sprint-related entities Also include: - Fix PocketBase file URL method casing (getUrl → getURL) across codebase - Add development utility, smoke test, and helper scripts - Update package.json with new sprint migration command
15 KiB
Agent Brief — Fill Prices on New Quotes (v3, 2026-07-02)
Your job: Find quotes with status="new", look at every line item that has no pricing yet, match it against the price list, fill it if you can, leave a note with the 3 closest catalogue candidates if you can't. Move to the next one.
This is v3. v1 had the security/auth smell of using a raw admin JWT. v2 inherited that — and the agent ended up running 26 separate PATCH round-trips per batch. v3 switches the writes to the new bulk_update_quote_items MCP tool — one JSON-RPC call covers the whole batch. v2's matcher logic, schema corrections and traps are unchanged and carried forward below as §3, §7, §8. Read §7 and §8 BEFORE writing anything.
1. Access
Use the MCP, not PB REST. The MCP server is on the same Next.js app — hit it with the agent bearer token (the one from your environment as WFDASH_AGENT_API_KEY or equivalent). Never mint a users/auth-with-password token; you don't need it and you should never see the admin JWT.
MCP="https://wfdash.example.com/api/mcp" # adjust to your env
TOKEN="${WFDASH_AGENT_API_KEY}" # bearer token in env
AUTH="Authorization: Bearer $TOKEN"
Token gotcha (unchanged from v2): if you assign the token in Python (
t = json.loads(out)['token']), Hermes's secret-redaction truncates it to ~13 chars and every subsequent request returns 0 results. Read it from an env var, not from a curl/Python pipe.
To verify auth + discovery, do a tools/list first:
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
"$MCP" | python3 -m json.tool
You should see bulk_update_quote_items in the list. If you don't, the operator hasn't deployed the latest MCP yet — stop and ask, don't fall back to PB REST.
2. The matcher (unchanged from v2)
Tier 1 — exact SKU match
Filter: sku="<sku>" || sku_normalised="<NORM>" where NORM = sku uppercased with non-alnum stripped. Sort -updated, perPage 5. If ≥ 1 hit → auto-fill.
Tier 2 — substring on SKU
Filter: sku_normalised~"<norm>" || sku~"<sku>". Sort -updated, perPage 5. If ≥ 1 hit → auto-fill.
Skip Tier 1 + 2 when SKU is empty (
sku==""). With~""the filter matches every catalogue row. Implementing requires an explicitif sku: ... else Tier 3branch in code, not just relying on the filter to be useless.
Tier 3 — fuzzy on description
Query = cleaned description (noise chars stripped: `" ( ) * ~ ``). Use this 4-step cascade:
A. Full cleaned query. Filter: search_text~"<cleaned>". perPage 10. From the candidates, pick the row whose description normalises equal to the quote line's description (modulo noise chars + whitespace + case). If exact match → auto-fill.
B. Dimension-AND filter. Extract every Nmm value AND every fractional inch (1/2, 2-1/4, 3/8) from the ORIGINAL (uncleaned) description. AND them together. perPage 30. Pick the row whose description normalises equal to the quote line's. If exact match → auto-fill.
C. Dimension-pair fallback. If B returns no exact match, try each pair of extracted dimensions AND-ed together. Pick exact match if found → auto-fill.
D. If no exact match anywhere → flag. Don't auto-fill a partial match. Set pricing_notes = "needs human review — closest catalogue candidates: …" with the top 3 by description similarity.
Tier 3 with 0 catalogue hits at all → flag with "no match"
pricing_notes = "needs human review — no catalogue match for '<query>'". Set user_edited = true. Do NOT leave the row silently unpriced.
Selling-price fallback
selling = catalogue.selling_price
|| round(catalogue.cost_price * 1.25 * 100) / 100
markup = round(((selling / cost) - 1) * 100) # %
3. The writes — one MCP call, no PB REST
Cataloguing the per-row decision in §2 produces a list of patches. Don't issue 26 PATCHes. Batch them into a single tools/call for bulk_update_quote_items.
The call
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"tools/call",
"params":{
"name":"bulk_update_quote_items",
"arguments":{
"dry_run": false,
"items": [
{ "id": "m9z3ubxog1jz7ye", "patch": {
"cost_price": 249.51, "selling_price": 311.89, "markup": 25,
"description": "HSS Annular Cutter 1/2\" (12.7mm) 25mm DOC",
"supplier": "q6hpwbxsicyoiuu", "unit": "each",
"pricing_source": "catalogue",
"matched_price_list_id": "wklkwmbkmstf53m",
"user_edited": true,
"pricing_notes": "Auto-filled by <your-agent-name> on <YYYY-MM-DD> from JEISA - WESTSCOTT GROUP (Tier 3-exact)"
}},
{ "id": "2zcarxk7p6a8lvg", "patch": {
"user_edited": true,
"pricing_notes": "needs human review — no catalogue match for \"circular saw blade 184mm\"\nClosest candidates:\n - <sku1> R<cost1> <desc1>\n - <sku2> R<cost2> <desc2>\n - <sku3> R<cost3> <desc3>"
}}
]
}
}
}' \
"$MCP"
For a probe-then-confirm workflow (recommended before a 20+ batch — see §6), pass "dry_run": true. The response shape is identical except results[].status is "dry_run" and update was not called. Zero side effects, real per-row feedback.
What the tool does for you
- Field whitelist — only
cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_editedare patchable. Any other key (id,quote,created,updated,quantity, …) is rejected withreason: forbidden_field: <list>. - Hand-edit guard — if a row already has
cost_price > 0 && selling_price > 0, the tool skips it withreason: already_priced. Passforce_overwrite: trueon that specific row if you really mean to overwrite the operator's number. - Per-row independence — one row's failure does not abort the batch. Returned per-row reasons:
forbidden_field,validation_error,not_found,already_priced,pb_error. For non-arrayitemsor batches > 100 the whole call fast-fails with a JSON-RPC error. - Caller stamps
pricing_notes— the agent knows who it is and the date. Include it in the patch when you change pricing fields (the existing/matchstyle assumes you will). The tool does NOT auto-inject an audit string.
Response shape (one row)
{
"ok": true,
"updated": 14,
"skipped": 0,
"errors": [],
"results": [
{
"id": "m9z3ubxog1jz7ye",
"status": "ok",
"previous_cost_price": 0,
"previous_selling_price": 0,
"new_cost_price": 249.51,
"new_selling_price": 311.89,
"updated_fields": ["cost_price","selling_price","markup", ...]
},
{ "id": "2zcarxk7p6a8lvg", "status": "ok", "...": "..." },
{ "id": "abc...", "status": "skipped", "reason": "already_priced",
"previous_cost_price": 100, "previous_selling_price": 125 }
],
"dry_run": false
}
ok: false means errors.length > 0 (some rows failed but the rest were applied). ok: true, updated: 0 is a no-op batch (everything skipped). Inspect errors[].reason for per-row diagnostics.
4. Decisions (unchanged from v2)
- "Confident match" = the candidate row's
descriptionnormalises equal to the quote line'sdescription(modulo noise chars, whitespace, case). Partial matches don't count — leave candidates. pricing_source="catalogue"only on a successful fill. Leave unset (or set to"manual") when you only left candidates.- Supplier resolution: just copy
price_list.supplier(the relation id) straight intoquote_items.supplier. If you need the human-readable name for the audit line, look it up viaGET /api/collections/suppliers/records/<id>and cache it. Don't try to resolve name → id — the catalogue already stores the id. - Quantity stays as-is — never modify it. (The MCP tool rejects
quantityin the whitelist anyway, so even if you pass it by accident it won't write.)
5. Don'ts
- Read from
price_listfreely — that's the whole point. Just don't write to it or delete from it. It's the source of truth for pricing. - Don't issue raw PB REST writes. All quote_item patches go through the MCP. Don't fall back to a
curl -X PATCHloop because the auth header looks shorter than you expected. - Don't delete anything from any collection.
- Don't overwrite a quote_item row that already has both
cost_price > 0ANDselling_price > 0(the operator may have hand-set it). Passforce_overwrite: trueif you really mean it. - Don't auto-write across > 20 rows without surfacing the histogram first and getting explicit confirmation (see §6).
- Don't trust
sort=-updatedas a tiebreaker — see §7.2. - Don't use bare
search_text~"<full description>"if the description contains",(,), or*— see §7.1. - Don't leave Tier 3 rows silently unpriced — always write a
pricing_notesflag. - Don't pass
id,quote,created,updated,quantity, or any other unwhitelisted field in the patch — the MCP will reject the row but you'll spend a round-trip learning it.
6. Probe-then-confirm workflow
- Walk all new quotes, paginate fully. Build the full list of unpriced items.
- Run the matcher on each. Build the patch list.
- Send the patches as
bulk_update_quote_itemswithdry_run: true. Get back per-row diagnostics. - Print histogram:
Tier 1 / Tier 2 / Tier 3-exact-fill / Tier 3-flag-with-candidates / Tier 3-flag-no-match / skipped (already priced). - Show sample fills and the top 3 fuzzy-match outliers.
- Stop and ask the operator which mode to run:
- Full auto-fill + flag — Tier 1/2/3-exact fill, Tier 3-partial/empty flag with
pricing_notes. Default mode. - Probe-only — keep
dry_run: true, write nothing. (Useful for a first look on a brand-new supplier.) - Report only — don't even call the MCP. Send the histogram and candidate list as a message.
- Full auto-fill + flag — Tier 1/2/3-exact fill, Tier 3-partial/empty flag with
- After confirmation, send the same batch with
dry_run: false. The MCP reports per-row outcomes — surfaceerrors[]in your final report so the operator sees what didn't land.
Exception: if the operator explicitly says "go" or "fill and flag them" in the prompt, skip step 5 and run the full MCP call immediately. The dry_run probe is operator-time, not a safety guarantee — the tool still rejects forbidden fields and already-priced rows for you.
7. Schema corrections vs the v1 brief (carried from v2)
| v1 said | v3 truth |
|---|---|
price_list.supplier is a display name string |
It's a relation id (15 chars). Copy verbatim. |
Tier 3: search_text~"<full desc>" works for any description |
Breaks on ", (, ), * — always strip noise chars or use dimension-AND |
Trust first -updated sort hit |
Bulk imports share updated dates; sort is effectively random. Always verify exact description match. |
| Flag candidates only when Tier 3 returns ≥ 1 hit | Also flag when Tier 3 returns 0 hits — otherwise rows disappear into the noise |
(new in v3) Use curl -X PATCH with an admin JWT for each row |
Use bulk_update_quote_items MCP tool — one call covers the whole batch, you never see the admin token |
8. Known traps
8.1 search_text~ chokes on ", (, ), *
PocketBase's substring operator parses the first " in your query as the end of the string literal and bails. Affected: every description with fractional-inch dimensions like HSS Annular Cutter 1/2" (12.7mm) 25mm DOC. The literal substring is in search_text — you just can't reach it through ~ with an unescaped quote.
Symptoms: Tier 3 returns 0 hits for items that visibly match the catalogue.
Fix: strip noise chars (`" ( ) * ~ ``) from the query, OR skip to step B of the Tier 3 cascade and use dimension-AND instead.
8.2 sort=-updated is unreliable after bulk imports
When a batch import lands, every imported row shares the same updated timestamp. sort=-updated then returns an effectively arbitrary order. The 18 carbide burrs in this catalogue all updated 2026-06-30; trusting the first hit gave us A0616 (head 6×16mm) when we needed D0605 (head 6×5mm).
Fix: from the candidate set, find the row whose description normalises equal to the quote line's description. Only fall back to "first by -updated" if no exact-normalised match exists — and even then, return top 3 candidates rather than auto-fill.
8.3 Empty-SKU guard
sku="" makes Tier 2's ~"" substring match every catalogue row (872 in our case). The script must short-circuit:
if not sku: # skip Tier 1 + 2
# go straight to Tier 3
8.4 Outrider — "test" description
Quote 7h1w44755zkvknw has a row with description = "test". It will match every TEST_GRINDER row in Tier 3. Do NOT auto-fill these — leave flagged.
8.5 Hermes redaction mangles the token
When a JWT is echoed back into a Python variable (e.g. from a Python-issued bearer), Hermes replaces the middle with asterisks. The variable ends up as eyJhbG...XXXXX (~13 chars). Subsequent requests succeed but return empty results because the auth header has garbage.
Fix: always read the MCP bearer from $WFDASH_AGENT_API_KEY (or whatever env var the operator set). Never assign the token in Python directly.
9. Final report
After running, report:
- How many quotes walked
- How many items filled (with totals: cost R…, sell R…)
- How many items flagged (and whether flag has candidates or "no match")
- How many items skipped as
already_priced(operator had hand-set them) - Any
errors[]from the MCP — usually empty, but ifpb_error: ...shows up the row landed a 4xx and you should retry that specific id - Any anomalies (sort weirdness, empty hits that should have matched, etc.)
10. Test fixtures (use to validate before touching real data)
Test catalogue rows: brand="TEST_GRINDER" — 5 rows (MAK-AG-125-9558, BOS-AG-125-GWS850, DEW-AG-125-DWE4257, MAK-AG-125-9558HD, GEN-DISC-125-CUT). All supplier q6hpwbxsicyoiuu.
Test quote 7lvyrbdmftr3arx (Q1782862717620): 3 items, mix of Tier 1 / Tier 2-3 / no-match. The circular saw blade 184mm row should produce Tier 3 with 0 hits and a "no catalogue match" flag.
Test quote 7h1w44755zkvknw: has a junk row with description="test" that produces 5 false-positive Tier 3 hits — must NOT auto-fill.
Run the matcher against the test fixtures first. Confirm the cascade produces the expected outcomes before running on real data. Use dry_run: true for the validation pass — zero writes, real diagnostics.