From 9254eecc9a347c62ba8d7f33f5786172b2dfdeae Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 07:03:30 +0200 Subject: [PATCH] docs(spec,plan): giellie stock quantity update The spec settles what was decided; the plan breaks it into 9 tasks with the code each one needs. Task order is the dependency order: the service learns to write a negative quantity, a pure resolver learns to map a pasted line to a stock item, a bulk service learns to apply and mirror, and only then does the MCP tool, the agent prompt, the approval card, the composer shortcut and the till tile hang off it. Two corrections to the spec while writing the plan: the approval card receives dotted MCP names, not model-facing ones, and the description match takes the top candidate unless two are tied at the top - a bare threshold would report 'ambiguous' for almost every description and defeat the feature. --- ...026-11-05-giellie-stock-quantity-update.md | 2088 +++++++++++++++++ ...05-giellie-stock-quantity-update-design.md | 2 +- 2 files changed, 2089 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md diff --git a/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md b/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md new file mode 100644 index 0000000..b3cd733 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md @@ -0,0 +1,2088 @@ +# Giellie stock quantity update — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the operator paste SKUs, descriptions and quantities into the Giellie chat and have the tracked stock quantities updated, audited, and visible at the till. + +**Architecture:** One new MCP tool (`stock_items.set_quantities`) resolves each pasted line against `stock_items`, computes the delta, and writes through the existing audited quantities service so the ledger row is stamped with the signed-in user. A pure resolver module does the matching, a bulk service does the PocketBase work, and the MCP route wires them together. Alongside it: a readable approval-card table, a "Stock update" shortcut in the chat composer, and a till tile that finally draws the quantity it is already being sent. + +**Tech Stack:** TypeScript, Next.js App Router route handlers, PocketBase (admin client), Anthropic SDK tool use, Vitest 3.2.4, plain ES modules for the POS PWA. + +**Spec:** `docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md` + +## Global Constraints + +- **Nothing blocks on low stock.** A quantity may be negative — in the tool and at the till. The POS already oversells on purpose (`src/lib/server/pos-service.ts:603`). +- **Quantities are whole units.** Every write truncates to an integer with `Math.trunc`. +- **The ledger row is written first**, then `current_qty`. Same order as `stock-quantities-service.ts` — a crash between the two leaves the audit trail ahead of the cached total. +- **The acting user is stamped** on every ledger row via `created_by: actor.id`. +- **Per-row independence.** One unmatched or ambiguous line never stops the rest of the batch. +- **The POS catalogue is `stock_items`.** Never the legacy `pos` collection — nothing writes to it. +- **Release notes carry no dates** — release number + commit SHA only. `src/lib/release-notes.test.ts` fails the build if a date appears. +- **Bump the POS service-worker cache constant** whenever `public/pos/pos-app.js` changes, or till devices keep the old file. `pos-v37` → `pos-v38`. +- **Never `git add -A`.** This tree carries unrelated untracked artifacts. Stage by explicit path. + +**Commands** + +```bash +npx vitest run # one test file +npx vitest run src/lib/server # a directory +npx tsc --noEmit # type check — the baseline is clean, so any error is yours +``` + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/server/stock-quantities-service.ts` | modify | Lift the non-negative floor for the agent; make the ledger note say where the edit came from | +| `src/lib/server/stock-quantity-match.ts` | create | Pure line → stock item resolution. No PocketBase | +| `src/lib/server/stock-quantity-match.test.ts` | create | The matching rules | +| `src/lib/server/stock-quantities-bulk-service.ts` | create | The PB work: fetch candidates, resolve, apply, mirror `price_list.stock_qty` | +| `src/lib/server/stock-quantities-bulk-service.test.ts` | create | Apply and mirror behaviour | +| `src/app/api/mcp/route.ts` | modify | The `stock_items.set_quantities` tool: schema + handler | +| `src/app/api/mcp/route.test.ts` | modify | Tool-level behaviour | +| `src/lib/server/giellie-tool-policy.ts` | modify | `dry_run: true` classifies as a read | +| `src/lib/server/giellie-tool-policy.catalogue.test.ts` | modify | The new tool is offered and classified | +| `src/lib/server/giellie-system-prompt.ts` | modify | The "Stock quantity updates" section | +| `src/components/giellie/approval-card.tsx` | modify | Table renderer for the new tool | +| `src/components/giellie/approval-card.test.tsx` | modify | The table renders; other tools keep JSON | +| `src/components/giellie/composer.tsx` | modify | The "Stock update" paste panel | +| `src/components/giellie/composer.test.tsx` | modify | The panel seeds the chat | +| `src/lib/server/pos-service.ts` | modify | `listActiveStockCatalog` also returns `min_qty` | +| `public/pos/pos-app.js` | modify | The tile shows the quantity | +| `public/pos/pos.html` | modify | CSS for the out-of-stock tile | +| `public/pos/sw.js` | modify | Cache bump | +| `.agents/skills/wfdash-giellie/SKILL.md` | modify | Document the new tool | +| `src/lib/release-notes.ts` | modify | Release 7 | + +--- + +### Task 1: Let the quantities service write a negative quantity + +The service today rejects a negative `current_qty`: + +```ts +function coerceNonNegativeInt(key: string, value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(`${key} must be a finite number >= 0`); + } + return Math.trunc(value); +} +``` + +That floor is right for the `/stock` page — the operator types a physical count. It is wrong for the agent, which must write an absolute count that can legitimately sit below zero. The flag is opt-in so the page is untouched. + +**Files:** +- Modify: `src/lib/server/stock-quantities-service.ts` +- Test: `src/lib/server/stock-quantities-service.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `UpdateStockItemsQuantitiesInput` gains `allowNegativeQty?: boolean` and `noteContext?: string`. `updateStockItemsQuantities` honours both. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/server/stock-quantities-service.test.ts`: + +```ts +describe("updateStockItemsQuantities — negative quantities", () => { + it("rejects a negative currentQty by default", async () => { + const { pb } = stubPb({ before: { id: "abcdefghijklmno", current_qty: 5 } }); + const res = await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: -1 }], + actor: { id: "u1", name: "Dave" }, + }); + const row = res.results[0]; + expect(row.ok).toBe(false); + expect(row.ok === false && row.reason).toBe("validation_error"); + }); + + it("accepts a negative currentQty when allowNegativeQty is set", async () => { + const { pb, ledgerCreates } = stubPb({ + before: { id: "abcdefghijklmno", current_qty: 5 }, + }); + const res = await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: -3 }], + actor: { id: "u1", name: "Dave" }, + allowNegativeQty: true, + }); + expect(res.summary.succeeded).toBe(1); + expect(ledgerCreates[0].change_qty).toBe(-8); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → -3 via Giellie"); + }); + + it("keeps minQty floored at zero even with allowNegativeQty", async () => { + const { pb } = stubPb({ before: { id: "abcdefghijklmno", current_qty: 5 } }); + const res = await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", minQty: -1 }], + actor: { id: "u1", name: "Dave" }, + allowNegativeQty: true, + }); + const row = res.results[0]; + expect(row.ok).toBe(false); + expect(row.ok === false && row.reason).toBe("validation_error"); + }); +}); + +describe("updateStockItemsQuantities — ledger note context", () => { + it("defaults the note to the /stock page", async () => { + const { pb, ledgerCreates } = stubPb({ + before: { id: "abcdefghijklmno", current_qty: 5 }, + }); + await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: 9 }], + actor: { id: "u1", name: "Dave" }, + }); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → 9 from /stock"); + }); + + it("uses the caller's context when given one", async () => { + const { pb, ledgerCreates } = stubPb({ + before: { id: "abcdefghijklmno", current_qty: 5 }, + }); + await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: 9 }], + actor: { id: "u1", name: "Dave" }, + noteContext: "via Giellie", + }); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → 9 via Giellie"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/server/stock-quantities-service.test.ts` + +Expected: FAIL — `allowNegativeQty` and `noteContext` are not on the input type, so TypeScript rejects the object literal, or the negative quantity is refused at runtime. + +- [ ] **Step 3: Implement the flag and the note context** + +In `src/lib/server/stock-quantities-service.ts`, replace the input type: + +```ts +export type UpdateStockItemsQuantitiesInput = { + /** PB client; passed in so tests can swap a stub. */ + pb: StockItemsPb; + updates: StockQuantityUpdate[]; + actor: { id: string; name: string }; + /** + * When true, `currentQty` may be negative. The /stock UI keeps its + * non-negative floor — the operator types a physical count there. The + * agent needs to write an absolute count that can legitimately sit + * below zero, because the till oversells on purpose. + */ + allowNegativeQty?: boolean; + /** + * Where the edit came from, appended to the ledger note. Defaults to + * "from /stock"; the agent passes "via Giellie". + */ + noteContext?: string; +}; +``` + +Replace `coerceNonNegativeInt` with: + +```ts +function coerceQty( + key: string, + value: unknown, + allowNegative: boolean, +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${key} must be a finite number`); + } + if (!allowNegative && value < 0) { + throw new Error(`${key} must be a finite number >= 0`); + } + return Math.trunc(value); +} +``` + +Find the validation block that calls `coerceNonNegativeInt(key, value)` and replace it with: + +```ts + try { + coerceQty( + key, + value, + key === "currentQty" && input.allowNegativeQty === true, + ); + } catch (err) { +``` + +Find the ledger write and replace the `notes` line: + +```ts + notes: `${input.actor.name} edited qty ${beforeCurrent} → ${requestedCurrent} ${input.noteContext ?? "from /stock"}`, +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/server/stock-quantities-service.test.ts` + +Expected: PASS — all tests in the file, including the pre-existing ones. The `/stock` page behaviour is unchanged because `allowNegativeQty` defaults to undefined. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-quantities-service.ts src/lib/server/stock-quantities-service.test.ts +git commit -m "feat(stock): allow the agent to write a negative current_qty + +The /stock page keeps its non-negative floor — the operator types a +physical count there. The agent has to be able to write an absolute +count that sits below zero, because the till oversells on purpose and +the stocktake is what corrects it. + +Also make the ledger note say where the edit came from, so an agent +write is not mislabelled as a /stock edit." +``` + +--- + +### Task 2: The line resolver + +Pure matching, no PocketBase. Takes the candidate rows as an argument so it is testable on its own. + +**Files:** +- Create: `src/lib/server/stock-quantity-match.ts` +- Test: `src/lib/server/stock-quantity-match.test.ts` + +**Interfaces:** +- Consumes: `normaliseSku` from `@/lib/stock-match`; `tokenize` and `jaccard` from `@/lib/server/order-quote-match`. +- Produces: + - `resolveStockQuantityLines(lines: StockQuantityLine[], candidates: StockQuantityCandidate[]): ResolvedStockQuantityLine[]` + - `DESCRIPTION_MATCH_THRESHOLD: number` + - Types `StockQuantityLine`, `StockQuantityCandidate`, `ResolvedStockQuantityLine`, `StockQuantityMatchMethod`. + +**Note on the description rule.** The operator chose "use it, flag it as fuzzy" for the case where the description *clearly identifies one item*. So the description step ranks the candidates and takes the top one — unless two candidates are tied at the top, which is the genuine ambiguity. A single threshold alone would report "ambiguous" for almost every description, which defeats the feature. + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/stock-quantity-match.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { resolveStockQuantityLines } from "./stock-quantity-match"; + +const CANDIDATES = [ + { + id: "id-abc", + sku: "M16-FLAT-WASHER", + name: "M16 Flat Washer", + description: "Flat washer M16 zinc plated", + current_qty: 12, + is_active: true, + }, + { + id: "id-def", + sku: "BOLT-M6-40", + name: "Bolt M6 40mm", + description: "Hex bolt M6 x 40mm", + current_qty: 5, + is_active: true, + }, + { + id: "id-ghi", + sku: "BOLT-M6-60", + name: "Bolt M6 60mm", + description: "Hex bolt M6 x 60mm", + current_qty: 0, + is_active: true, + }, + { + id: "id-jkl", + sku: "OLD-ITEM", + name: "Retired Widget", + description: "No longer sold", + current_qty: 3, + is_active: false, + }, +]; + +describe("resolveStockQuantityLines", () => { + it("matches an exact SKU", () => { + const out = resolveStockQuantityLines( + [{ sku: "M16-FLAT-WASHER", qty: 50 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("sku_exact"); + expect(out[0].item?.id).toBe("id-abc"); + }); + + it("matches a SKU that differs only in case and punctuation", () => { + const out = resolveStockQuantityLines( + [{ sku: "m16 flat washer", qty: 50 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("sku_exact"); + expect(out[0].item?.id).toBe("id-abc"); + }); + + it("matches on description and flags it as a fuzzy match", () => { + const out = resolveStockQuantityLines( + [{ description: "Hex bolt M6 x 40mm", qty: 120 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("description_fuzzy"); + expect(out[0].item?.id).toBe("id-def"); + }); + + it("reports a line with no identifier as unmatched", () => { + const out = resolveStockQuantityLines([{ qty: 5 }], CANDIDATES); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/neither a sku nor a description/i); + }); + + it("reports a SKU that matches nothing as unmatched", () => { + const out = resolveStockQuantityLines([{ sku: "ZZZ-9", qty: 30 }], CANDIDATES); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/no active stock item/i); + }); + + it("does not write to an inactive item and says why", () => { + const out = resolveStockQuantityLines([{ sku: "OLD-ITEM", qty: 3 }], CANDIDATES); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/inactive/i); + }); + + it("reports two candidates with the same SKU as ambiguous", () => { + const out = resolveStockQuantityLines( + [{ sku: "BOLT-M6", qty: 10 }], + [ + ...CANDIDATES, + { id: "id-dup", sku: "BOLT-M6", name: "Bolt M6", description: "", current_qty: 1 }, + ], + ); + expect(out[0].status).toBe("ambiguous"); + expect(out[0].candidates?.length).toBe(2); + }); + + it("keeps the line index so callers can map rows back", () => { + const out = resolveStockQuantityLines( + [{ sku: "ZZZ-9", qty: 1 }, { sku: "M16-FLAT-WASHER", qty: 2 }], + CANDIDATES, + ); + expect(out[0].index).toBe(0); + expect(out[1].index).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/server/stock-quantity-match.test.ts` + +Expected: FAIL — `./stock-quantity-match` does not exist. + +- [ ] **Step 3: Implement the resolver** + +Create `src/lib/server/stock-quantity-match.ts`: + +```ts +/** + * Stock quantity line resolution — pure. No PocketBase, no I/O. + * + * The operator pastes lines of "SKU description qty" into the Giellie + * chat. This module decides which stock item each line means. It is + * kept pure so the matching rules can be tested without a database and + * so the same rules can be reused by a future batch-import path. + * + * Resolution order per line: + * 1. Exact SKU, normalised (uppercase, punctuation stripped). One + * hit matches; two or more are ambiguous; a single inactive hit is + * reported as unmatched with the reason. + * 2. Description match — tokenised Jaccard against the item's name + * and description. The top score wins unless two candidates are + * tied at the top, which is the genuine ambiguity. The operator + * asked for this to be used rather than refused, so it is flagged + * as a fuzzy match instead of being silent. + */ + +import { normaliseSku } from "@/lib/stock-match"; +import { jaccard, tokenize } from "@/lib/server/order-quote-match"; + +/** Tokenised Jaccard score at or above which a description counts as a match. */ +export const DESCRIPTION_MATCH_THRESHOLD = 0.6; + +export type StockQuantityLine = { + sku?: string; + description?: string; + qty: number; +}; + +export type StockQuantityCandidate = { + id: string; + sku?: string; + name?: string; + description?: string; + current_qty?: number; + is_active?: boolean; +}; + +export type StockQuantityMatchMethod = "sku_exact" | "description_fuzzy"; + +export type ResolvedStockQuantityLine = { + index: number; + sku: string; + description: string; + qty: number; + status: "matched" | "unmatched" | "ambiguous"; + match_method?: StockQuantityMatchMethod; + item?: StockQuantityCandidate; + candidates?: StockQuantityCandidate[]; + message?: string; +}; + +export function resolveStockQuantityLines( + lines: StockQuantityLine[], + candidates: StockQuantityCandidate[], +): ResolvedStockQuantityLine[] { + return lines.map((line, index) => { + const sku = (line.sku ?? "").trim(); + const description = (line.description ?? "").trim(); + const base = { index, sku, description, qty: line.qty }; + + if (!sku && !description) { + return { + ...base, + status: "unmatched", + message: "Line has neither a sku nor a description.", + }; + } + + if (sku) { + const norm = normaliseSku(sku); + const hits = norm + ? candidates.filter((c) => normaliseSku(c.sku) === norm) + : []; + if (hits.length > 1) { + return { + ...base, + status: "ambiguous", + candidates: hits, + message: `${hits.length} stock items share the SKU "${sku}".`, + }; + } + if (hits.length === 1) { + const hit = hits[0]; + if (hit.is_active === false) { + return { + ...base, + status: "unmatched", + message: `SKU "${sku}" matches "${hit.name ?? hit.id}", but that stock item is inactive.`, + }; + } + return { + ...base, + status: "matched", + match_method: "sku_exact", + item: hit, + }; + } + } + + if (description) { + const wanted = tokenize(description); + if (wanted.size > 0) { + const scored = candidates + .filter((c) => c.is_active !== false) + .map((c) => ({ + item: c, + score: jaccard(wanted, tokenize(`${c.name ?? ""} ${c.description ?? ""}`)), + })) + .filter((s) => s.score >= DESCRIPTION_MATCH_THRESHOLD) + .sort((a, b) => b.score - a.score); + + if (scored.length > 0) { + const top = scored[0]; + const runnerUp = scored[1]; + const tied = runnerUp !== undefined && runnerUp.score === top.score; + if (!tied) { + return { + ...base, + status: "matched", + match_method: "description_fuzzy", + item: top.item, + }; + } + const tiedRows = scored.filter((s) => s.score === top.score); + return { + ...base, + status: "ambiguous", + candidates: tiedRows.map((s) => s.item), + message: `"${description}" matches ${tiedRows.length} stock items equally well.`, + }; + } + } + } + + return { + ...base, + status: "unmatched", + message: "No active stock item matches this SKU or description.", + }; + }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/server/stock-quantity-match.test.ts` + +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-quantity-match.ts src/lib/server/stock-quantity-match.test.ts +git commit -m "feat(stock): resolve pasted stock lines to stock items + +Pure matching, no PocketBase, so the rules can be tested on their own. +Exact SKU first (normalised), then a tokenised Jaccard description +match against the item's name and description. + +The description match takes the top candidate unless two are tied at +the top — a single threshold alone would report 'ambiguous' for almost +every description and defeat the feature." +``` + +--- + +### Task 3: The bulk service + +The PocketBase work: fetch candidates, resolve, apply through the audited service, and mirror `price_list.stock_qty`. + +**Files:** +- Create: `src/lib/server/stock-quantities-bulk-service.ts` +- Test: `src/lib/server/stock-quantities-bulk-service.test.ts` + +**Interfaces:** +- Consumes: `resolveStockQuantityLines` from Task 2; `updateStockItemsQuantities` and `StockItemsPb` from `stock-quantities-service`; `normaliseSku` and `escapeForPbString` from `@/lib/server/price-list-match`. +- Produces: `setStockQuantitiesBulk(input): Promise` and its types. + +**Note on the two `normaliseSku` helpers.** `@/lib/stock-match` strips whitespace and punctuation and uppercases; `@/lib/server/price-list-match` strips everything that is not A-Z or 0-9 and uppercases. They produce the same string. The resolver uses the first, the catalogue lookup uses the second, because `price_list.sku_normalised` was written with the second. + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/stock-quantities-bulk-service.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { setStockQuantitiesBulk } from "./stock-quantities-bulk-service"; + +const STOCK_ITEMS = [ + { + id: "id-abc", + sku: "M16-FLAT-WASHER", + name: "M16 Flat Washer", + description: "Flat washer M16", + current_qty: 12, + is_active: true, + }, + { + id: "id-def", + sku: "BOLT-M6-40", + name: "Bolt M6 40mm", + description: "Hex bolt M6 x 40mm", + current_qty: 5, + is_active: true, + }, +]; + +function stubPb(priceListRows: Array> = []) { + const calls: string[] = []; + const ledgerCreates: Array> = []; + const stockUpdates: Array<{ id: string; patch: Record }> = []; + const priceListUpdates: Array<{ id: string; patch: Record }> = []; + + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getFullList: vi.fn().mockResolvedValue(STOCK_ITEMS), + getOne: vi.fn().mockImplementation(async (id: string) => ({ + ...STOCK_ITEMS.find((i) => i.id === id), + })), + update: vi.fn().mockImplementation(async (id: string, patch: Record) => { + calls.push("stock_items.update"); + stockUpdates.push({ id, patch }); + return { id, ...patch }; + }), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { + create: vi.fn().mockImplementation(async (row: Record) => { + calls.push("stock_ledger.create"); + ledgerCreates.push(row); + return { id: "ledger1", ...row }; + }), + }; + } + if (name === "price_list") { + return { + getList: vi.fn().mockResolvedValue({ + items: priceListRows, + totalItems: priceListRows.length, + }), + update: vi.fn().mockImplementation(async (id: string, patch: Record) => { + priceListUpdates.push({ id, patch }); + return { id, ...patch }; + }), + }; + } + throw new Error(`unexpected collection ${name}`); + }, + }; + + return { pb, calls, ledgerCreates, stockUpdates, priceListUpdates }; +} + +describe("setStockQuantitiesBulk — dry run", () => { + it("plans without writing anything", async () => { + const { pb, calls, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: true, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.results[0].previous_qty).toBe(12); + expect(res.results[0].new_qty).toBe(50); + expect(res.results[0].delta).toBe(38); + expect(ledgerCreates).toHaveLength(0); + expect(stockUpdates).toHaveLength(0); + expect(calls).toHaveLength(0); + }); +}); + +describe("setStockQuantitiesBulk — applying", () => { + it("writes the ledger row before the quantity, stamped with the actor", async () => { + const { pb, calls, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(calls[0]).toBe("stock_ledger.create"); + expect(calls[1]).toBe("stock_items.update"); + expect(ledgerCreates[0].change_qty).toBe(38); + expect(ledgerCreates[0].created_by).toBe("u1"); + expect(ledgerCreates[0].notes).toContain("via Giellie"); + expect(stockUpdates[0].patch.current_qty).toBe(50); + }); + + it("adds to the current quantity in add mode", async () => { + const { pb, ledgerCreates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 8 }], + mode: "add", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.results[0].new_qty).toBe(20); + expect(ledgerCreates[0].change_qty).toBe(8); + }); + + it("reports a line that already has that quantity as unchanged and writes nothing", async () => { + const { pb, ledgerCreates, calls } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 12 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.unchanged).toBe(1); + expect(ledgerCreates).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + it("writes a negative quantity without complaint", async () => { + const { pb, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: -20 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.results[0].new_qty).toBe(-20); + expect(ledgerCreates[0].change_qty).toBe(-32); + expect(stockUpdates[0].patch.current_qty).toBe(-20); + }); + + it("applies the good lines and reports the unmatched one", async () => { + const { pb, ledgerCreates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [ + { sku: "M16-FLAT-WASHER", qty: 50 }, + { sku: "ZZZ-9", qty: 30 }, + ], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.summary.unmatched).toBe(1); + expect(res.results[1].status).toBe("unmatched"); + expect(ledgerCreates).toHaveLength(1); + }); +}); + +describe("setStockQuantitiesBulk — price_list mirror", () => { + it("mirrors the new quantity onto every catalogue row for that SKU", async () => { + const { pb, priceListUpdates } = stubPb([ + { id: "pl1", sku: "M16-FLAT-WASHER", sku_normalised: "M16FLATWASHER" }, + { id: "pl2", sku: "M16 Flat Washer", sku_normalised: "M16FLATWASHER" }, + ]); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.results[0].price_list_rows_touched).toBe(2); + expect(priceListUpdates).toHaveLength(2); + expect(priceListUpdates[0].patch.stock_qty).toBe(50); + }); + + it("reports zero catalogue rows without failing the write", async () => { + const { pb, ledgerCreates } = stubPb([]); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.results[0].price_list_rows_touched).toBe(0); + expect(ledgerCreates).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/server/stock-quantities-bulk-service.test.ts` + +Expected: FAIL — `./stock-quantities-bulk-service` does not exist. + +- [ ] **Step 3: Implement the bulk service** + +Create `src/lib/server/stock-quantities-bulk-service.ts`: + +```ts +/** + * Bulk stock quantity writes — the engine behind the MCP tool + * `stock_items.set_quantities`. + * + * The operator pastes lines of "SKU description qty" into the Giellie + * chat. This service resolves them against `stock_items`, works out the + * signed delta, and hands the write to `updateStockItemsQuantities` so + * the stock_ledger row is written first and stamped with the acting + * user. It then mirrors the new quantity onto `price_list.stock_qty`, + * which is a denormalised snapshot of our own quantity (see + * `price-list-service.ts`). + * + * Nothing here blocks on a low or negative quantity. The till oversells + * on purpose and the stocktake is what corrects it. + */ + +import { + updateStockItemsQuantities, + type StockItemsPb, +} from "./stock-quantities-service"; +import { + resolveStockQuantityLines, + type ResolvedStockQuantityLine, + type StockQuantityCandidate, + type StockQuantityLine, +} from "./stock-quantity-match"; +import { escapeForPbString, normaliseSku } from "@/lib/server/price-list-match"; + +export type StockQuantityMode = "set" | "add"; + +export type StockQuantitiesBulkPb = { + collection: (id: string) => { + getFullList: (opts?: Record) => Promise; + getList: ( + page: number, + perPage: number, + opts?: Record, + ) => Promise<{ items: T[]; totalItems: number }>; + getOne: (id: string, opts?: Record) => Promise; + update: (id: string, patch: Record) => Promise; + create: (data: Record) => Promise; + }; +}; + +export type StockQuantitiesBulkInput = { + pb: StockQuantitiesBulkPb; + lines: StockQuantityLine[]; + mode: StockQuantityMode; + dryRun: boolean; + actor: { id: string; name: string }; +}; + +export type StockQuantitiesBulkRowStatus = + | "applied" + | "unchanged" + | "unmatched" + | "ambiguous"; + +export type StockQuantitiesBulkResultRow = { + sku: string; + description: string; + qty: number; + status: StockQuantitiesBulkRowStatus; + match_method?: "sku_exact" | "description_fuzzy"; + id?: string; + name?: string; + previous_qty?: number; + new_qty?: number; + delta?: number; + price_list_rows_touched?: number; + candidates?: Array<{ id: string; name: string; current_qty: number }>; + message?: string; +}; + +export type StockQuantitiesBulkResult = { + ok: boolean; + mode: StockQuantityMode; + dry_run: boolean; + summary: { + total: number; + applied: number; + unchanged: number; + unmatched: number; + ambiguous: number; + }; + results: StockQuantitiesBulkResultRow[]; +}; + +type PriceListRow = { + id: string; + sku?: string; + sku_normalised?: string; +}; + +type StockCandidate = { + id: string; + sku?: string; + name?: string; + description?: string; + current_qty?: number; + is_active?: boolean; +}; + +function toCandidate(row: StockCandidate): StockQuantityCandidate { + return { + id: row.id, + sku: row.sku, + name: row.name, + description: row.description, + current_qty: Number(row.current_qty ?? 0), + is_active: row.is_active !== false, + }; +} + +/** + * Mirror our quantity onto the price-list catalogue. The catalogue holds + * one row per SKU per supplier, so one stock item can touch several + * rows. Matching is on the canonical SKU form, which is what the + * catalogue's own `sku_normalised` column stores. + */ +async function mirrorPriceListQty( + pb: StockQuantitiesBulkPb, + rawSku: string, + newQty: number, +): Promise { + const norm = normaliseSku(rawSku); + if (!norm) return 0; + + const page = await pb.collection("price_list").getList(1, 200, { + filter: `sku_normalised = "${norm}" || sku ~ "${escapeForPbString(rawSku)}"`, + }); + + const rows = page.items.filter((r) => normaliseSku(r.sku) === norm); + for (const row of rows) { + await pb.collection("price_list").update(row.id, { stock_qty: newQty }); + } + return rows.length; +} + +function candidateSummary(candidates: StockQuantityCandidate[]) { + return candidates.map((c) => ({ + id: c.id, + name: c.name ?? c.id, + current_qty: Number(c.current_qty ?? 0), + })); +} + +export async function setStockQuantitiesBulk( + input: StockQuantitiesBulkInput, +): Promise { + const { pb, lines, mode, dryRun, actor } = input; + + const raw = + (await pb.collection("stock_items").getFullList({ + fields: "id,sku,name,description,current_qty,is_active", + })) ?? []; + + const resolved: ResolvedStockQuantityLine[] = resolveStockQuantityLines( + lines, + raw.map(toCandidate), + ); + + // Work out the target for every matched line before touching anything. + const planned = resolved.map((line) => { + if (line.status !== "matched" || !line.item) { + return { line, target: undefined, delta: undefined }; + } + const current = Number(line.item.current_qty ?? 0); + const target = mode === "set" ? line.qty : current + line.qty; + return { line, target, delta: target - current }; + }); + + const toWrite = planned + .filter( + (p): p is { + line: ResolvedStockQuantityLine & { + item: NonNullable; + }; + target: number; + delta: number; + } => + p.line.status === "matched" && + p.target !== undefined && + p.delta !== undefined && + p.delta !== 0, + ) + .map((p) => ({ id: p.line.item.id, currentQty: p.target })); + + let writeResults: Awaited>["results"] = []; + if (!dryRun && toWrite.length > 0) { + const write = await updateStockItemsQuantities({ + pb: pb as unknown as StockItemsPb, + updates: toWrite, + actor, + allowNegativeQty: true, + noteContext: "via Giellie", + }); + writeResults = write.results; + } + + const results: StockQuantitiesBulkResultRow[] = []; + const summary = { + total: lines.length, + applied: 0, + unchanged: 0, + unmatched: 0, + ambiguous: 0, + }; + + for (const p of planned) { + const line = p.line; + const base: StockQuantitiesBulkResultRow = { + sku: line.sku, + description: line.description, + qty: line.qty, + }; + + if (line.status === "ambiguous") { + summary.ambiguous += 1; + results.push({ + ...base, + status: "ambiguous", + candidates: candidateSummary(line.candidates ?? []), + message: line.message, + }); + continue; + } + + if (line.status === "unmatched" || !line.item || p.target === undefined) { + summary.unmatched += 1; + results.push({ ...base, status: "unmatched", message: line.message }); + continue; + } + + const current = Number(line.item.current_qty ?? 0); + const delta = p.target - current; + + if (delta === 0) { + summary.unchanged += 1; + results.push({ + ...base, + status: "unchanged", + match_method: line.match_method, + id: line.item.id, + name: line.item.name, + previous_qty: current, + new_qty: p.target, + delta: 0, + }); + continue; + } + + const writeRow = writeResults.find((r) => r.id === line.item?.id); + + if (!writeRow || writeRow.ok === false) { + summary.unmatched += 1; + results.push({ + ...base, + status: "unmatched", + match_method: line.match_method, + id: line.item.id, + name: line.item.name, + previous_qty: current, + message: + writeRow && writeRow.ok === false + ? writeRow.message + : "The quantity write did not complete.", + }); + continue; + } + + summary.applied += 1; + let priceListRowsTouched = 0; + if (!dryRun && line.item.sku) { + priceListRowsTouched = await mirrorPriceListQty(pb, line.item.sku, p.target); + } + + results.push({ + ...base, + status: "applied", + match_method: line.match_method, + id: line.item.id, + name: line.item.name, + previous_qty: current, + new_qty: p.target, + delta, + price_list_rows_touched: priceListRowsTouched, + }); + } + + return { + ok: true, + mode, + dry_run: dryRun, + summary, + results, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/server/stock-quantities-bulk-service.test.ts` + +Expected: PASS — 9 tests. + +- [ ] **Step 5: Run the whole server test folder to check nothing else broke** + +Run: `npx vitest run src/lib/server` + +Expected: PASS — the new files plus every pre-existing test. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/stock-quantities-bulk-service.ts src/lib/server/stock-quantities-bulk-service.test.ts +git commit -m "feat(stock): bulk quantity writes with a price-list mirror + +Resolves pasted lines, computes the signed delta, and hands the write +to updateStockItemsQuantities so the ledger row is written first and +stamped with the acting user. A negative result is a normal outcome, +not an error — the till oversells on purpose. + +Mirrors the new quantity onto price_list.stock_qty, which is a +denormalised snapshot of our own quantity. The catalogue holds one row +per SKU per supplier, so one item can touch several rows; the count is +reported per line." +``` + +--- + +### Task 4: The MCP tool + +**Files:** +- Modify: `src/app/api/mcp/route.ts` +- Test: `src/app/api/mcp/route.test.ts` + +**Interfaces:** +- Consumes: `setStockQuantitiesBulk` from Task 3. +- Produces: the MCP tool `stock_items.set_quantities`, offered to the model as `stock_items__set_quantities`. + +- [ ] **Step 1: Write the failing test** + +Append to `src/app/api/mcp/route.test.ts`: + +```ts +describe("MCP tool stock_items.set_quantities", () => { + it("is declared in the tool catalogue", async () => { + const res = await GET( + new NextRequest("http://localhost/api/mcp", { + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY}` }, + }), + ); + const body = (await res.json()) as { tools: Array<{ name: string }> }; + expect(body.tools.some((t) => t.name === "stock_items.set_quantities")).toBe(true); + }); + + it("rejects a batch with a duplicate SKU", async () => { + const res = await POST( + new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "stock_items.set_quantities", + arguments: { + mode: "set", + lines: [ + { sku: "ABC-1", qty: 1 }, + { sku: "abc 1", qty: 2 }, + ], + }, + }, + }), + }), + ); + const body = await res.json(); + expect(JSON.stringify(body)).toMatch(/duplicate SKU/i); + }); + + it("rejects an unknown mode", async () => { + const res = await POST( + new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "stock_items.set_quantities", + arguments: { mode: "sideways", lines: [{ sku: "ABC-1", qty: 1 }] }, + }, + }), + }), + ); + const body = await res.json(); + expect(JSON.stringify(body)).toMatch(/mode must be/i); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/app/api/mcp/route.test.ts` + +Expected: FAIL — the tool is not in the catalogue. + +- [ ] **Step 3: Declare the tool** + +In `src/app/api/mcp/route.ts`, find the `stock_items.set_pricing` entry in the tool list and add this entry directly after it: + +```ts + { + name: "stock_items.set_quantities", + description: + "Set or change the stock quantity on one or more POS/stock items, matched by SKU and description. " + + 'Use mode "set" when the number is the new quantity on hand (the stocktake case), and mode "add" when it is a change to add to the current quantity. ' + + "Each line is resolved by exact SKU first, then by a description match (flagged as such). A line that cannot be resolved is reported, never guessed. " + + "Pass dry_run true first to preview the before/after plan. Quantities may be negative — the till oversells on purpose and the stocktake corrects it. " + + "Writes a stock_ledger row against the acting user and mirrors the new quantity onto the price-list catalogue.", + inputSchema: { + type: "object", + properties: { + mode: { + type: "string", + enum: ["set", "add"], + description: + '"set" = the number is the new quantity on hand. "add" = the number is added to the current quantity.', + }, + lines: { + type: "array", + minItems: 1, + maxItems: 100, + description: + "One line per stock item. Give the sku when you have it; the description is the fallback when the SKU does not match.", + items: { + type: "object", + properties: { + sku: { type: "string", description: "The stock item's SKU." }, + description: { + type: "string", + description: "Free-text description, used when the SKU does not match.", + }, + qty: { + type: "number", + description: + "The quantity. In set mode this is the new quantity on hand; in add mode it is the change.", + }, + }, + required: ["qty"], + }, + }, + dry_run: { + type: "boolean", + description: + "Plan the change without writing anything. Returns the same result shape with the same before/after numbers.", + }, + }, + required: ["mode", "lines"], + }, + }, +``` + +- [ ] **Step 4: Handle the call** + +Find the `stock_items.set_pricing` handler in the same file and add this branch immediately after it: + +```ts + if (toolName === "stock_items.set_quantities") { + const mode = String(args.mode ?? ""); + if (mode !== "set" && mode !== "add") { + throw new Error('mode must be "set" or "add"'); + } + + const rawLines = Array.isArray(args.lines) ? (args.lines as unknown[]) : []; + if (rawLines.length < 1 || rawLines.length > 100) { + throw new Error("lines must contain between 1 and 100 entries"); + } + + const lines = rawLines.map((raw) => { + const l = (raw ?? {}) as { sku?: unknown; description?: unknown; qty?: unknown }; + const qty = + typeof l.qty === "number" && Number.isFinite(l.qty) ? Math.trunc(l.qty) : NaN; + return { + sku: typeof l.sku === "string" ? l.sku : undefined, + description: typeof l.description === "string" ? l.description : undefined, + qty, + }; + }); + + if (lines.some((l) => !Number.isFinite(l.qty))) { + throw new Error("every line needs a finite numeric qty"); + } + + // One SKU is one stock row, so two lines for the same SKU cannot + // both be true. That is a malformed paste, not a per-row outcome. + const seen = new Set(); + const duplicates = new Set(); + for (const l of lines) { + const key = normaliseSku(l.sku ?? ""); + if (!key) continue; + if (seen.has(key)) duplicates.add(l.sku ?? key); + seen.add(key); + } + if (duplicates.size > 0) { + throw new Error(`duplicate SKU in one batch: ${[...duplicates].join(", ")}`); + } + + const actorRaw = (args.actor ?? {}) as { id?: string; name?: string }; + const result = await setStockQuantitiesBulk({ + pb, + lines, + mode, + dryRun: args.dry_run === true, + actor: { + id: typeof actorRaw.id === "string" ? actorRaw.id : "", + name: + typeof actorRaw.name === "string" && actorRaw.name ? actorRaw.name : "Giellie", + }, + }); + return jsonRpcOk(id, result); + } +``` + +Add the imports at the top of the file, next to the other service imports: + +```ts +import { setStockQuantitiesBulk } from "@/lib/server/stock-quantities-bulk-service"; +import { normaliseSku } from "@/lib/server/price-list-match"; +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/app/api/mcp/route.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Type-check** + +Run: `npx tsc --noEmit` + +Expected: no errors. The baseline was clean, so anything here is yours. + +- [ ] **Step 7: Commit** + +```bash +git add src/app/api/mcp/route.ts src/app/api/mcp/route.test.ts +git commit -m "feat(mcp): stock_items.set_quantities + +One call sets the stock quantity on up to 100 pasted lines. Each line +is resolved by exact SKU, then by description, and reports what it +could not resolve instead of guessing. + +The batch is validated up front — a duplicate SKU is a malformed +paste, not a per-row failure — while a line that simply does not match +is reported and the rest of the batch still applies." +``` + +--- + +### Task 5: Tell the agent how to use it + +**Files:** +- Modify: `src/lib/server/giellie-tool-policy.ts` +- Modify: `src/lib/server/giellie-tool-policy.catalogue.test.ts` +- Modify: `src/lib/server/giellie-system-prompt.ts` +- Test: `src/lib/server/giellie-tool-policy.test.ts` +- Test: `src/lib/server/giellie-system-prompt.test.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `classifyToolCall("stock_items.set_quantities", { dry_run: true })` returns `{ cls: "read" }`. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/server/giellie-tool-policy.test.ts`: + +```ts +describe("stock_items.set_quantities", () => { + it("is a write when it will actually write", () => { + expect( + classifyToolCall("stock_items.set_quantities", { mode: "set", lines: [] }).cls, + ).toBe("write"); + }); + + it("is a read when dry_run is true, so the preview never asks", () => { + expect( + classifyToolCall("stock_items.set_quantities", { + mode: "set", + lines: [], + dry_run: true, + }).cls, + ).toBe("read"); + }); +}); +``` + +In `src/lib/server/giellie-tool-policy.catalogue.test.ts`, add after the `stock_items.set_pricing` case: + +```ts + it("offers stock_items.set_quantities to the model as a write", () => { + expect(tools.some((t) => t.name === "stock_items.set_quantities")).toBe(true); + const offered = toAnthropicTools(tools).map((t) => fromModelToolName(t.name)); + expect(offered).toContain("stock_items.set_quantities"); + expect(classifyTool("stock_items.set_quantities")).toBe("write"); + expect(READ_TOOLS.has("stock_items.set_quantities")).toBe(false); + expect(DENIED_TOOLS.has("stock_items.set_quantities")).toBe(false); + expect(CONFIRM_TOOLS.has("stock_items.set_quantities")).toBe(false); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/server/giellie-tool-policy.test.ts src/lib/server/giellie-tool-policy.catalogue.test.ts` + +Expected: FAIL — the dry run is still classified as a write, and the catalogue does not have the tool. + +- [ ] **Step 3: Add the dry-run rule** + +In `src/lib/server/giellie-tool-policy.ts`, inside `classifyToolCall`, add this before the `base === "write"` branch: + +```ts + // A dry run writes nothing, so it must not raise an approval card. + if ( + name === "stock_items.set_quantities" && + (input as { dry_run?: unknown } | null)?.dry_run === true + ) { + return { cls: "read" }; + } +``` + +- [ ] **Step 4: Add the system prompt section** + +In `src/lib/server/giellie-system-prompt.ts`, add this section to the prompt string: + +``` +## Stock quantity updates + +When the operator pastes stock lines (a SKU or description with a quantity): + +1. **Always ask first** whether the numbers are the new quantity on hand or a + change to add to the current stock. Do not infer it from the wording, and do + not write anything until they answer. +2. Resolve the lines with `stock_items.set_quantities`. Never loop + `stock_items.list` and `stock_items.adjust` by hand — that path takes a + signed delta, has no batch call, and writes a ledger row with no actor. +3. Call it with `dry_run: true` first and show the operator the plan as a + table: SKU, item, before, after, status. Point out the lines that did not + match and the ones that were ambiguous. +4. Only when they say to apply, call it again with `dry_run: false`. +5. Report the outcome: applied, unchanged, unmatched, ambiguous. +6. Quantities may be negative and nothing blocks on a low quantity. The till + oversells on purpose and the stocktake is what corrects it. + +Use `price_list.add_items` only when the operator explicitly asks for the +price list or supplier catalogue. It is not the way to load prices or stock +onto the counter. +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/server/giellie-tool-policy.test.ts src/lib/server/giellie-tool-policy.catalogue.test.ts src/lib/server/giellie-system-prompt.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/giellie-tool-policy.ts src/lib/server/giellie-tool-policy.catalogue.test.ts src/lib/server/giellie-tool-policy.test.ts src/lib/server/giellie-system-prompt.ts src/lib/server/giellie-system-prompt.test.ts +git commit -m "feat(giellie): teach the agent the stock quantity workflow + +The dry run is a read, so the preview never raises an approval card. +The prompt says to ask whether the numbers are the new quantity on +hand or an addition, every time, before writing anything." +``` + +--- + +### Task 6: A readable approval card + +Today every tool renders raw JSON. A 40-line stocktake in JSON is unreviewable. + +**Files:** +- Modify: `src/components/giellie/approval-card.tsx` +- Test: `src/components/giellie/approval-card.test.tsx` + +**Interfaces:** +- Consumes: nothing. +- Produces: `renderToolInput(toolName: string, input: unknown): ReactNode` exported from `approval-card.tsx`. + +The card receives **dotted** MCP names — `PendingToolUse.name` is documented as `orders.update` — so the renderer matches `stock_items.set_quantities` directly. + +- [ ] **Step 1: Write the failing test** + +Append to `src/components/giellie/approval-card.test.tsx`: + +```tsx +describe("the stock quantity tool renders a table", () => { + it("lists the sku, description and qty", () => { + render( + , + ); + expect(screen.getByText("ABC-123")).toBeTruthy(); + expect(screen.getByText("Bolt M12")).toBeTruthy(); + expect(screen.getByText("50")).toBeTruthy(); + expect(screen.getByText(/new quantity on hand/i)).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/"lines"/); + }); + + it("says when the quantities are being added instead of set", () => { + render( + , + ); + expect(screen.getByText(/adding these quantities/i)).toBeTruthy(); + }); + + it("leaves other tools as JSON", () => { + render( + , + ); + expect(document.body.textContent).toMatch(/"status"/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/giellie/approval-card.test.tsx` + +Expected: FAIL — the card renders JSON for every tool. + +- [ ] **Step 3: Implement the renderer** + +In `src/components/giellie/approval-card.tsx`, add above the component: + +```tsx +type StockQuantityLine = { sku?: string; description?: string; qty?: number }; + +/** + * The stock quantity tool is the one tool whose input is a list the + * operator has to read line by line. JSON is not reviewable at that + * size, so it gets a table. Every other tool keeps raw JSON. + */ +export function renderToolInput(toolName: string, input: unknown): ReactNode { + if (toolName === "stock_items.set_quantities" && input && typeof input === "object") { + const payload = input as { mode?: unknown; lines?: unknown }; + const mode = typeof payload.mode === "string" ? payload.mode : ""; + const lines = Array.isArray(payload.lines) ? (payload.lines as StockQuantityLine[]) : []; + if (lines.length > 0) { + return ( +
+

+ {mode === "add" + ? "Adding these quantities to the current stock." + : "Setting these as the new quantity on hand."} +

+
+ + + + + + + + + + {lines.map((line, i) => ( + + + + + + ))} + +
SKUDescriptionQty
{line.sku ?? "—"}{line.description ?? "—"}{line.qty ?? "—"}
+
+
+ ); + } + } + return
{JSON.stringify(input, null, 2)}
; +} +``` + +Find the line in the component that renders the JSON and replace it: + +```tsx +
{JSON.stringify(tu.input, null, 2)}
+``` + +with: + +```tsx + {renderToolInput(tu.name, tu.input)} +``` + +Make sure `ReactNode` is imported. If the file does not already import it from react, add: + +```tsx +import type { ReactNode } from "react"; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/components/giellie/approval-card.test.tsx` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/giellie/approval-card.tsx src/components/giellie/approval-card.test.tsx +git commit -m "feat(giellie): show stock quantity approvals as a table + +A 40-line stocktake in raw JSON is unreviewable. The stock quantity +tool gets a table with the mode, SKU, description and quantity. +Every other tool keeps its JSON exactly as it was." +``` + +--- + +### Task 7: The "Stock update" shortcut + +**Files:** +- Modify: `src/components/giellie/composer.tsx` +- Test: `src/components/giellie/composer.test.tsx` + +**Interfaces:** +- Consumes: the existing `onSend(text, files, onAccepted)` prop. +- Produces: a "Stock update" button that opens a paste panel and sends the pasted lines to the chat. + +The button is a shortcut only. It does **not** carry the set/add intent — the operator chose to be asked that every time, so Giellie asks it in the conversation. + +- [ ] **Step 1: Write the failing test** + +Append to `src/components/giellie/composer.test.tsx`: + +```tsx +describe("the stock update shortcut", () => { + it("sends the pasted lines to Giellie", () => { + const onSend = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /stock update/i })); + fireEvent.change(screen.getByPlaceholderText(/ABC-123 50/i), { + target: { value: "ABC-123 50\nBOLT-6MM 120" }, + }); + fireEvent.click(screen.getByRole("button", { name: /send to giellie/i })); + + expect(onSend).toHaveBeenCalledTimes(1); + const sent = String(onSend.mock.calls[0][0]); + expect(sent).toContain("ABC-123 50"); + expect(sent).toContain("BOLT-6MM 120"); + }); + + it("does not send an empty list", () => { + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /stock update/i })); + fireEvent.click(screen.getByRole("button", { name: /send to giellie/i })); + expect(onSend).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/giellie/composer.test.tsx` + +Expected: FAIL — there is no stock update button. + +- [ ] **Step 3: Implement the panel** + +In `src/components/giellie/composer.tsx`, add state next to the existing `text` / `files` state: + +```tsx + const [stockOpen, setStockOpen] = useState(false); + const [stockText, setStockText] = useState(""); +``` + +Add the handler: + +```tsx + function sendStockList() { + const list = stockText.trim(); + if (!list || disabled) return; + onSend( + `Update the stock quantities on these lines.\n\n${list}`, + [], + () => { + setStockText(""); + setStockOpen(false); + }, + ); + } +``` + +Add the button next to the existing icon buttons, and the panel next to the textarea: + +```tsx + +``` + +```tsx + {stockOpen && ( +
+