Giellie can now update stock quantities from a pasted list. The operator
pastes SKUs and quantities into the chat; Giellie resolves each line
against stock_items, asks whether the numbers are the new quantity on
hand or an addition, shows the plan, and writes the change against the
signed-in operator's name in the stock ledger.
The write path is ledger-first and audited, a failed write is its own
status and is compensated so the ledger cannot drift from the quantity,
and a dry run writes nothing at all. Nothing blocks on a low quantity -
the till oversells on purpose and the stocktake is what corrects it.
The till now shows the quantity on each catalogue tile, drawn under the
price as it is, negatives included.
The system prompt justified avoiding the stock_items.list +
stock_items.adjust loop by claiming that path "writes a ledger row with no
actor". That stopped being true when the branch routed adjust through the
audited write service: adjust now requires actor.id and its ledger rows
carry created_by. The instruction itself is unchanged and not weakened —
the signed delta and the missing batch call are still the reasons to
avoid the loop, and the new tool's own advantages are now named.
stock_items.create now also requires actor.id, so an external MCP client
that omits it gets a 500 JSON-RPC error. The generated catalogue row is
truncated at 240 chars before that sentence, so both authoritative docs
get a hand-written row and an input-shape block instead. The generator is
not re-run; the catalogue is still 148 tools.
The spec's till bullet described the first design, not the code that
shipped: the quantity is drawn under the price on the right (the
operator's own decision during the build), and the service-worker cache
was bumped to `pos-v39`.
- The tile bullet now points at `pos-app.js:290` and says the `.sub`
line keeps `sku · brand · unit · M in cart` while the quantity sits
under the price.
- The cache bump reads `pos-v37` -> `pos-v39`.
`.agents/skills/wfdash-giellie/SKILL.md` says "trust the docs", so the
two source-of-truth docs override the branch's guidance — and they still
sent every quantity write to the pre-branch `stock_items.adjust`.
- Regenerated the tool catalogue with the generator the doc itself
names (`python3 scripts/mcp-doc-gen.py`): the count is now 148 (was
144), `stock_items.set_quantities` is in the catalogue and in the
alphabetical index, and the `stock_items.adjust` row carries its new
description. `python3 scripts/mcp-doc-gen.py --check` now passes.
- Added a `stock_items.set_quantities` row to the stock tool table in
both docs, matching the tool's real input shape: `mode` ("set" |
"add"), `lines` (1-100, each `{ sku?, description?, qty }`),
`dry_run`, plus the required `actor.id`.
- Repointed the quantity guidance in both docs away from
`stock_items.adjust` and toward `stock_items.set_quantities`, and
marked `adjust` as superseded for quantity work.
The ledger asked the final review to close this gap, and it was still
open: the negative-quantity test asserted the ledger row (the delta and
the note) but never the persisted `stock_items` patch, and nothing pinned
a negative `targetQty` under `allowNegativeQty` — where the code applies
the negative allowance only to `currentQty`.
- The negative-quantity test now also asserts the persisted patch
carries `current_qty: -3`.
- A new test asserts a negative `targetQty` is still rejected while
`allowNegativeQty` is set, and that nothing is written.
Creating a stock item with `current_qty > 0` wrote the opening-balance
`stock_ledger` row with `created_by: ""`, so the row landed in the audit
trail unattributed — and it was written after the quantity, so a failure
in between left the two out of step.
- The create now requires `actor.id` (the same 15-char check and error
style as `stock_items.set_quantities`) and stamps the opening-balance
row with it.
- The opening balance is booked through the audited write path: the item
is created with no quantity, the ledger row is written first, and the
quantity second. If the quantity write fails, the service reverses
the ledger row, so `SUM(change_qty)` still rebuilds `current_qty` and a
retry is safe.
- A zero or negative opening balance is written on the create itself,
exactly as before.
`stock_items.adjust` wrote its `stock_ledger` row with no acting user
(`created_by: ""`), and it updated `current_qty` with no compensation —
the same orphan-ledger shape the new quantity tool removed. The docs
advertise this tool, so it is a load-bearing write path.
- `updateStockItemsQuantities` takes optional `reason`, `referenceType`,
`referenceId` and `notes` overrides. The defaults are unchanged
("adjustment", "manual", the service's own note), so the /stock page
and the agent write exactly what they wrote before.
- The adjust handler now requires `actor.id` (the same 15-char check and
error style as `stock_items.set_quantities`), reads the row's current
quantity, turns the signed delta into an absolute target and calls the
service. Attribution, ledger-first ordering and the compensating
reversal row come from the service instead of being duplicated here.
- The caller's `reason` and `notes` survive the conversion, and a
negative resulting quantity is allowed — the till oversells on purpose.
- The tool description marks the tool as superseded for quantity work
and points at `stock_items.set_quantities`.
The quantity went into the .sub line, which is nowrap + ellipsis: a
28- or 38-character SKU truncated it, so the number fell off the right
edge and pushed the in-cart count out with it. The sub line is back to
what it was — sku, brand, unit, N in cart — and the quantity now sits
under the price, right-aligned, where nothing truncates it.
The POS tests asserted the file's prose rather than its expressions, so
five mutations of the implementation all passed them: a min_qty cue, a
lost zero boundary, a clamp, a round and the segment deleted. They now
pin the expressions, the interpolation, the class wiring and the dark
rule's colour value. All seven mutations tried are killed.
A non-numeric current_qty rendered "NaN in stock" and was not badged —
NaN <= 0 is false. It now falls back to 0 and badges.
Bumped the service-worker cache: pos-app.js and pos.html are both
served cache-first out of the shell, so without a new CACHE name the
tills keep the old ones.
The catalog endpoint already sends current_qty and the till already
receives it — pos-app.js just never drew it. The number is shown as it
is, negatives included, and the tile is styled as out of stock when
the quantity is not positive.
The cue is current_qty <= 0, not current_qty <= min_qty: only 48 of
the 4,083 sellable items have a reorder point set, so a min-based rule
would badge every tile and mean nothing.
Nothing is blocked. The cashier can still sell the item and the
quantity still goes into minus.
The shortcut was an unlabelled icon sitting between the paperclip and the
camera, so on a touch device it read as a third kind of attachment. It now
carries a visible "Stock update" label and sits on its own row under the
input row, which also gives the textbox back the width the third icon took.
The paste box gets an accessible name, a max height so a long list cannot
take over the screen, and the trigger reports whether the panel is open.
Opens a paste panel for the SKU and quantity lines and sends them to
the chat. It carries no set/add intent — the operator is asked that
every time — so it is only a shortcut into the conversation.
The card is fed the model's raw tool input, so a malformed line (a
null, or a qty that is not a number) reached the renderer unchecked
and null.sku threw during render — taking the card, and its
Approve/Reject buttons, with it.
Lines are now read the way the MCP route reads them: an unreadable
line is reported in place, the good lines beside it still render, and
the operator is told that approving rejects the whole batch.
The headings stay put as well. The table now rides inside the card's
own scroller instead of a second scroll region, the header row is
sticky on an opaque background, and the set/add note rides with it —
so every row of a 40-line stocktake is read with its labels and its
mode in view.
On a dry run nothing is written, so a row the engine reports as
applied only means it would be applied. The tool description said
none of that, and a reader of the description alone still got the
old ambiguity.
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.
The engine reports a row as applied on a dry run when it only
would be applied, so the preview now says so and states that nothing
has changed yet. The prompt also maps the answer to the mode value
and names the fields the catalogue and the till each keep.
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.
The route treats the string true as a dry run, so the policy must too,
otherwise a dry run still raises an approval card for a write that never
happens.
The batch ran with an empty actor.id when the caller sent none, so
the ledger row went out unattributed — against the plan's own rule
that the acting user is stamped on every ledger row. The tool now
requires a real 15-char user id, exactly as stock_items.set_pricing
does, and declares the actor in its schema so a schema-following
client can supply one.
The other fixes follow from the same review:
- A numeric SKU is a SKU, so it is coerced to its string form; a
boolean or an object is malformed input and is refused rather
than dropped silently.
- The duplicate-SKU error names both spellings and the lines they
are on, so the operator can see which two rows collided.
- dry_run also accepts the string "true". It is the one flag that
decides whether data is written at all.
- The description now says the batch is rejected as a whole on a
repeated SKU, and that quantities are truncated to whole units.
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.
The ledger row is written first on purpose, so a crash cannot leave the
audit trail behind the cached total. But when the quantity write then
fails, the ledger sat ahead of the quantity - and the operator was told
to retry, which wrote a second ledger row for the same change and
double-counted it.
A failed quantity write now reverses its own ledger row, so SUM(change_qty)
still reconstructs current_qty and a retry is safe.
The price_list mirror could reject — a bad filter, a 400, a network
blip — and the rejection escaped setStockQuantitiesBulk. The
quantities had already been written and their ledger rows were in
place, so the caller got an exception instead of a summary: the
remaining lines were never reported, and a retry re-applied the
quantities and wrote a second set of ledger rows, double-counting the
audit trail.
The mirror is now guarded. The quantity landed, so the row stays
"applied" — reporting it as "failed" would say the quantity did not
change, which is false. The row carries price_list_rows_touched: 0
and a message saying the catalogue snapshot failed and why. The batch
always returns its summary and results.
Also: "ok" now means what it says — summary.failed === 0 — instead of
always being true. The price-list page is unbounded by design at this
data volume (about 2,900 catalogue rows over about 2,700 SKUs), and
the comment now says so, with the caveat that the filter's raw-SKU
LIKE clause treats % and _ as wildcards, so the page is sized by
substring noise rather than by the SKU's own rows.
The write service puts the stock_ledger row first and then patches
current_qty. When that second write failed, the ledger row was left
behind: the ledger sat one change ahead of the quantity, and a retry
wrote a second row for the same change, so SUM(change_qty) no longer
rebuilt current_qty — the premise a drift-repair job rests on.
The failed write is now compensated with a ledger row for the same
item carrying the opposite change_qty, stamped with the same actor
and noted as a reversal of the adjustment. If the compensation itself
fails, the row is still reported as failed with a message that says
the ledger could not be reversed. The failed row's message says when
the ledger was compensated, so a retry is safe.
The ledger-first ordering is unchanged — it is what makes a crash
between the two writes leave the audit trail ahead of the cached
total.
Reporting a failed write as an unmatched line tells the operator they
typed the SKU wrong when the cause was a transient database error. That
sends them hunting for a typo instead of retrying - different problems,
different responses.
Also records the dry-run fix: on a dry run a matched line is reported
applied with its before and after numbers, and nothing is written.
A write that did not complete now has its own status, "failed", and
its own summary count. Reporting it as "unmatched" sent the operator
looking for a typo in the SKU when the real cause was a PocketBase
error — two different problems with two different responses, so they
no longer share a status. "unmatched" keeps its meaning: the line
never resolved to an active stock item.
The five counts still add up to "total".
The price-list mirror reads one 200-row page. The catalogue holds
about 2,900 rows over about 2,700 SKUs, so a SKU has one row per
supplier and a handful at most: the page is unbounded by design at
this data volume, not an oversight.
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.
Three changes from the Task 2 review.
The SKU string gets a second chance at matching by how the item is
named. When the exact SKU misses, the line's description AND its SKU
are joined and scored as text, so a line carrying only a SKU still
finds its item. The match method stays "description_fuzzy" - it is a
text match either way, and the threshold and the top-unless-tied rule
are unchanged.
An inactive best match now names itself. The text step scores every
candidate, active or inactive, exactly as the SKU step does, so an
inactive row is no longer filtered out before scoring - it is reported
as unmatched with its name and the reason, instead of vanishing and
leaving a bare "no match" behind.
The resolver truncates the quantity. Quantities are whole units, so
the dry-run plan and the stored value now agree instead of the plan
reporting 12.7 for a write that stores 12.
The operator decided that when a pasted SKU matches nothing exactly,
the SKU string should get a second chance at matching by how the item
is named - pasting "M16 Flat Washer" into the SKU box should still find
the flat washer.
The line's match text is now the description and the SKU joined, so a
line carrying only a SKU still gets a text match. Everything else about
the text step is unchanged: the same 0.6 threshold, the same "top
candidate wins unless two are tied at the top" rule.
Also from the Task 2 review: an inactive best match now names itself
instead of vanishing, the resolver truncates the quantity so the dry-run
plan and the stored value agree, and the test list picks up the
fall-through path that had no coverage.
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.
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.
The approval card renders the JSON inside a <pre> with max-h-48 classes,
the composer already imports Textarea, and the POS tile is built in a
template string rather than a class-join expression. Each instruction
now names the code that is actually there.
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.
Giellie can already reach stock_items, but only through ad-hoc
list+adjust loops: a signed delta instead of an absolute count, no
batch call, fuzzy SKU picks the model cannot see are wrong, and
stock_items.adjust throws the actor away so the ledger row has no
attribution.
Adds a stock_items.set_quantities MCP tool that resolves each pasted
line (exact SKU, then description fuzzy), computes the delta, and
writes through updateStockItemsQuantities so the ledger is audited
against the signed-in user. Also mirrors price_list.stock_qty, which
price-list-service.ts documents as a denormalised snapshot of
current_qty.
The live data makes this matter: 522 of the 4,083 sellable items carry
a negative tracked quantity and 2,901 sit at zero. That drift is by
design - the till oversells on purpose (pos-service.ts:603) and is
corrected at the next stocktake, which is what this feature is.
Nothing blocks on low stock. One service flag (allowNegativeQty)
lifts the non-negative floor for the agent's writes only, leaving the
/stock page untouched.
Records live under /api/collections/<name>/records, and the admin client
authenticates with PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD — the PB_ADMIN_TOKEN
in .env is not always current.
The date was kept in the registry as an authoring reference and never
rendered, but it was still a date sitting in the notes file, and a
date-shaped value can always find its way to a screen. The ReleaseNote
type has no date field at all now, so one cannot be added without a
deliberate change to the contract.
A test walks the registry and fails on any date — a stray property, a
headline or a line of copy — so the popup carries a release number and
a commit and nothing else.
Release 6 also says what it is: Payments and Approvals is in beta. The
schema and the enforcement hooks are live on the PocketBase, but the
backend behind the queue is still being worked on.
The payments and approvals queue, the theme rework and the release
number on the popup all landed since release 5, so the office is told
about them in one place.
The entry is the release number and the commit, never a date, and the
popup still renders no date of any kind.
The item supplier payments and approvals queue: a payment hold on order
items, the Payments and Approvals page, supporting documents and proof
of payment, and the PB hook that stops an unpaid item becoming ready for
collection.
The theme registry carried a hand-maintained set of hex swatches for
every palette, and they had drifted away from the CSS the app actually
paints. The picker previewed colours the product never showed.
The registry now holds only what cannot be read from CSS — id, label,
tagline, fonts, radius — and the picker, the Settings swatches and the
preview cards resolve --background, --primary and --foreground off the
theme itself. A preview can no longer disagree with the screen it
previews.
themes.css is rewritten so every palette carries a real dark surface,
and the palettes that are dark by nature stay dark whichever way the
mode switch is set. The pre-hydration script validates the stored theme
id against the registry, so a retired id falls back to WFDASH instead of
painting an unstyled page, and the old "warm" mode migrates to Light.
The legacy text-{warn,success,info}-foreground classes now resolve to
the semantic text colour and switch to the filled ink only on an exact
solid fill, so one class reads correctly on both a tinted badge and a
filled button. A solid focus outline replaces the faint ring, and the
charts and quote status badges take their colours from tokens rather
than hard-coded dark strokes.
Tests: theme-bootstrap.test.tsx covers saved-theme recovery and the
storage-blocked path; e2e/themes.spec.ts walks every palette in both
modes checking surface contrast, tinted and solid semantic pairs, focus
colour, chart tokens and the destructive hover, and asserts that the
picker preview matches the applied theme.
Remove payment amount, reference, notes and manual combining forms. Link supplier POs automatically and record paid from supplier evidence plus proof, keeping readiness and attachment safeguards.
Keep active supplier/company views current and retire stale supplier evidence on routing changes. Verified 5167 tests with only the two existing script-import errors.
Keep payment holds independent of workflow and margin approval. Require proof before collection readiness; deduplicate uploads transactionally in PocketBase and preserve payment history.
Includes schema migration, deployment runbook, browser checks and isolated PocketBase regression coverage. Full suite has 5156 passing tests and retains two pre-existing script-import errors.
The popup rendered the day each release went out, which gave the office
a way to ask what was done on a given day. Each release now shows its
number and the short SHA it shipped with, and the date-shaped version
string is gone from the registry.
The date formatter is deleted rather than hidden, so no code path can
put a date on screen, and a test fails the build if one comes back.
The 'seen' marker moves from the version string to the release number,
so the badge lights up once for everyone on this deploy and then
settles.
The version strings are dates ("2026.11.01"), so a release has no
identity that is not a date. Every entry now also carries a release
number and the short SHA of the commit it went out with.
The date-shaped version string is left in place for now — the popup
switches over and drops it in the next commit, so this one stays
green on its own.
A test holds the numbering unique, contiguous and newest-first, and
holds every commit to a real SHA.